[优化] 重写搜索核心
This commit is contained in:
@@ -4,18 +4,13 @@ import io.legado.app.data.AppDatabase
|
|||||||
import io.legado.app.data.entities.BookSourcePart
|
import io.legado.app.data.entities.BookSourcePart
|
||||||
import io.legado.app.data.entities.SearchBook
|
import io.legado.app.data.entities.SearchBook
|
||||||
import io.legado.app.data.entities.SearchKeyword
|
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.domain.usecase.BookShelfKey
|
||||||
import io.legado.app.help.book.isNotShelf
|
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 io.legado.app.ui.main.bookshelf.BookShelfItem
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -31,41 +26,11 @@ interface SearchRepository {
|
|||||||
suspend fun saveSearchKeyword(keyword: String)
|
suspend fun saveSearchKeyword(keyword: String)
|
||||||
suspend fun deleteSearchKeyword(item: SearchKeyword)
|
suspend fun deleteSearchKeyword(item: SearchKeyword)
|
||||||
suspend fun clearSearchKeywords()
|
suspend fun clearSearchKeywords()
|
||||||
|
|
||||||
fun createSearchSession(scopeProvider: () -> SearchScope): SearchSession
|
|
||||||
}
|
|
||||||
|
|
||||||
sealed interface SearchSessionEvent {
|
|
||||||
data object Started : SearchSessionEvent
|
|
||||||
|
|
||||||
data class Progress(
|
|
||||||
val upsertBooks: List<SearchBook>,
|
|
||||||
val removedBookUrls: List<String>,
|
|
||||||
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<SearchSessionEvent>
|
|
||||||
fun search(searchId: Long, keyword: String)
|
|
||||||
fun stop()
|
|
||||||
fun pause()
|
|
||||||
fun resume()
|
|
||||||
fun close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class SearchRepositoryImpl(
|
class SearchRepositoryImpl(
|
||||||
private val appDb: AppDatabase,
|
private val appDb: AppDatabase,
|
||||||
) : SearchRepository {
|
) : SearchRepository, BookSearchGateway {
|
||||||
|
|
||||||
override val enabledGroups: Flow<List<String>> = appDb.bookSourceDao.flowEnabledGroups()
|
override val enabledGroups: Flow<List<String>> = appDb.bookSourceDao.flowEnabledGroups()
|
||||||
override val enabledSources: Flow<List<BookSourcePart>> = appDb.bookSourceDao.flowEnabled()
|
override val enabledSources: Flow<List<BookSourcePart>> = appDb.bookSourceDao.flowEnabled()
|
||||||
@@ -113,71 +78,34 @@ class SearchRepositoryImpl(
|
|||||||
appDb.searchKeywordDao.deleteAll()
|
appDb.searchKeywordDao.deleteAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun createSearchSession(scopeProvider: () -> SearchScope): SearchSession {
|
override suspend fun getBookSourceParts(scope: BookSearchScope): List<BookSourcePart> =
|
||||||
return SearchSessionImpl(scopeProvider)
|
withContext(Dispatchers.IO) {
|
||||||
|
val selectedSources = linkedSetOf<BookSourcePart>()
|
||||||
|
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(
|
override suspend fun saveSearchBooks(books: List<SearchBook>) = withContext(Dispatchers.IO) {
|
||||||
scopeProvider: () -> SearchScope,
|
if (books.isNotEmpty()) {
|
||||||
) : SearchSession {
|
appDb.searchBookDao.insert(books)
|
||||||
|
|
||||||
private val sessionScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
||||||
private val _events = MutableSharedFlow<SearchSessionEvent>(extraBufferCapacity = 8)
|
|
||||||
override val events: Flow<SearchSessionEvent> = _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<SearchBook>,
|
|
||||||
removedBookUrls: List<String>,
|
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<BookSourcePart>
|
||||||
|
suspend fun getBookSource(sourceUrl: String): BookSource?
|
||||||
|
suspend fun saveSearchBooks(books: List<SearchBook>)
|
||||||
|
}
|
||||||
@@ -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<String>
|
||||||
|
get() = raw.splitNotBlank(",").toList()
|
||||||
|
|
||||||
|
val isAll: Boolean
|
||||||
|
get() = raw.isEmpty()
|
||||||
|
|
||||||
|
val isSource: Boolean
|
||||||
|
get() = items.isNotEmpty() && parseSourceItems().size == items.size
|
||||||
|
|
||||||
|
val groupNames: List<String>
|
||||||
|
get() = if (isSource) emptyList() else items
|
||||||
|
|
||||||
|
val sourceUrls: List<String>
|
||||||
|
get() = parseSourceItems().map { it.url }
|
||||||
|
|
||||||
|
private fun parseSourceItems(): List<ScopeSourceItem> {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<SearchBook>,
|
||||||
|
val removedBookUrls: List<String>,
|
||||||
|
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<SearchRunEvent> = 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<SearchBook>) : 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<SearchBookKey, SearchBook>()
|
||||||
|
private val containsBooks = LinkedHashMap<SearchBookKey, SearchBook>()
|
||||||
|
private val otherBooks = LinkedHashMap<SearchBookKey, SearchBook>()
|
||||||
|
var resultLimitReached = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
val count: Int
|
||||||
|
get() = equalBooks.size + containsBooks.size + otherBooks.size
|
||||||
|
|
||||||
|
suspend fun merge(newBooks: List<SearchBook>): SearchBookChange {
|
||||||
|
if (newBooks.isEmpty()) return SearchBookChange()
|
||||||
|
|
||||||
|
val upsertBooks = arrayListOf<SearchBook>()
|
||||||
|
val removedBookUrls = linkedSetOf<String>()
|
||||||
|
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<SearchBookKey, SearchBook>? {
|
||||||
|
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<SearchBookKey, SearchBook>): SearchBook? {
|
||||||
|
val key = bucket.keys.lastOrNull() ?: return null
|
||||||
|
return bucket.remove(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeLowestOrigin(bucket: LinkedHashMap<SearchBookKey, SearchBook>): 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<SearchBook> = emptyList(),
|
||||||
|
val removedBookUrls: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<BookSourcePart>()
|
|
||||||
private val equalBooks = LinkedHashMap<SearchBookKey, SearchBook>()
|
|
||||||
private val containsBooks = LinkedHashMap<SearchBookKey, SearchBook>()
|
|
||||||
private val otherBooks = LinkedHashMap<SearchBookKey, SearchBook>()
|
|
||||||
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<SearchBook>, precision: Boolean): SearchBookChange {
|
|
||||||
if (newDataS.isEmpty()) {
|
|
||||||
return SearchBookChange()
|
|
||||||
}
|
|
||||||
|
|
||||||
val upsertBooks = arrayListOf<SearchBook>()
|
|
||||||
val removedBookUrls = linkedSetOf<String>()
|
|
||||||
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<SearchBookKey, SearchBook>? {
|
|
||||||
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<SearchBookKey, SearchBook>): SearchBook? {
|
|
||||||
val key = bucket.keys.lastOrNull() ?: return null
|
|
||||||
return bucket.remove(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun removeLowestOrigin(bucket: LinkedHashMap<SearchBookKey, SearchBook>): 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<SearchBook>,
|
|
||||||
removedBookUrls: List<String>,
|
|
||||||
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<SearchBook> = emptyList(),
|
|
||||||
val removedBookUrls: List<String> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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.GlassTopAppBarDefaults
|
||||||
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarScrollBehavior
|
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarScrollBehavior
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -178,6 +179,12 @@ fun SearchScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DisposableEffect(viewModel) {
|
||||||
|
onDispose {
|
||||||
|
viewModel.onIntent(SearchIntent.StopSearch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val submitSearch: (String) -> Unit = { rawQuery ->
|
val submitSearch: (String) -> Unit = { rawQuery ->
|
||||||
val normalized = rawQuery.trim()
|
val normalized = rawQuery.trim()
|
||||||
if (normalized.isNotBlank()) {
|
if (normalized.isNotBlank()) {
|
||||||
|
|||||||
@@ -6,14 +6,21 @@ import io.legado.app.constant.PreferKey
|
|||||||
import io.legado.app.data.entities.BookSourcePart
|
import io.legado.app.data.entities.BookSourcePart
|
||||||
import io.legado.app.data.entities.SearchBook
|
import io.legado.app.data.entities.SearchBook
|
||||||
import io.legado.app.data.repository.SearchRepository
|
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.BookShelfKey
|
||||||
import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
|
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.help.config.AppConfig
|
||||||
|
import io.legado.app.ui.config.otherConfig.OtherConfig
|
||||||
import io.legado.app.ui.main.bookshelf.BookShelfItem
|
import io.legado.app.ui.main.bookshelf.BookShelfItem
|
||||||
import io.legado.app.utils.getPrefBoolean
|
import io.legado.app.utils.getPrefBoolean
|
||||||
import io.legado.app.utils.putPrefBoolean
|
import io.legado.app.utils.putPrefBoolean
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
@@ -30,6 +37,7 @@ import splitties.init.appCtx
|
|||||||
class SearchViewModel(
|
class SearchViewModel(
|
||||||
private val repository: SearchRepository,
|
private val repository: SearchRepository,
|
||||||
private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase,
|
private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase,
|
||||||
|
private val searchBooksUseCase: SearchBooksUseCase,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(
|
private val _uiState = MutableStateFlow(
|
||||||
@@ -49,10 +57,11 @@ class SearchViewModel(
|
|||||||
private val queryFlow = MutableStateFlow("")
|
private val queryFlow = MutableStateFlow("")
|
||||||
private val bookshelfKeys = MutableStateFlow<Set<BookShelfKey>>(emptySet())
|
private val bookshelfKeys = MutableStateFlow<Set<BookShelfKey>>(emptySet())
|
||||||
private val searchScope = SearchScope(AppConfig.searchScope)
|
private val searchScope = SearchScope(AppConfig.searchScope)
|
||||||
private val searchSession = repository.createSearchSession { searchScope }
|
private val searchControl = BookSearchControl()
|
||||||
private val searchResultBooks = LinkedHashMap<String, SearchBook>()
|
private val searchResultBooks = LinkedHashMap<String, SearchBook>()
|
||||||
|
|
||||||
private var currentSearchId = 0L
|
private var searchJob: Job? = null
|
||||||
|
private var currentSearchPage = 1
|
||||||
|
|
||||||
init {
|
init {
|
||||||
syncScopeState()
|
syncScopeState()
|
||||||
@@ -61,7 +70,6 @@ class SearchViewModel(
|
|||||||
observeBookshelf()
|
observeBookshelf()
|
||||||
observeQueryHistory()
|
observeQueryHistory()
|
||||||
observeQueryBookshelfHints()
|
observeQueryBookshelfHints()
|
||||||
observeSearchSession()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onIntent(intent: SearchIntent) {
|
fun onIntent(intent: SearchIntent) {
|
||||||
@@ -71,8 +79,8 @@ class SearchViewModel(
|
|||||||
SearchIntent.SubmitSearch -> submitSearch()
|
SearchIntent.SubmitSearch -> submitSearch()
|
||||||
SearchIntent.LoadMore -> loadMore()
|
SearchIntent.LoadMore -> loadMore()
|
||||||
SearchIntent.StopSearch -> stopSearch()
|
SearchIntent.StopSearch -> stopSearch()
|
||||||
SearchIntent.PauseEngine -> searchSession.pause()
|
SearchIntent.PauseEngine -> searchControl.pause()
|
||||||
SearchIntent.ResumeEngine -> searchSession.resume()
|
SearchIntent.ResumeEngine -> searchControl.resume()
|
||||||
is SearchIntent.UseHistoryKeyword -> {
|
is SearchIntent.UseHistoryKeyword -> {
|
||||||
updateQuery(intent.keyword, showSuggestions = false)
|
updateQuery(intent.keyword, showSuggestions = false)
|
||||||
submitSearch(intent.keyword)
|
submitSearch(intent.keyword)
|
||||||
@@ -145,8 +153,8 @@ class SearchViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
|
stopSearch(manualStop = false)
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
searchSession.close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initialize(key: String?, scopeRaw: String?) {
|
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) {
|
private fun updateQuery(query: String, showSuggestions: Boolean) {
|
||||||
if (showSuggestions && _uiState.value.isSearching) {
|
if (showSuggestions && _uiState.value.isSearching) {
|
||||||
searchSession.stop()
|
stopSearch(manualStop = false)
|
||||||
}
|
}
|
||||||
queryFlow.value = query
|
queryFlow.value = query
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
@@ -297,7 +252,7 @@ class SearchViewModel(
|
|||||||
|
|
||||||
updateQuery(keyword, showSuggestions = false)
|
updateQuery(keyword, showSuggestions = false)
|
||||||
|
|
||||||
currentSearchId = System.currentTimeMillis()
|
currentSearchPage = 1
|
||||||
searchResultBooks.clear()
|
searchResultBooks.clear()
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
@@ -314,7 +269,7 @@ class SearchViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.saveSearchKeyword(keyword)
|
repository.saveSearchKeyword(keyword)
|
||||||
}
|
}
|
||||||
searchSession.search(currentSearchId, keyword)
|
startSearch(keyword, currentSearchPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadMore() {
|
private fun loadMore() {
|
||||||
@@ -323,18 +278,95 @@ class SearchViewModel(
|
|||||||
if (state.committedQuery.isBlank()) return
|
if (state.committedQuery.isBlank()) return
|
||||||
if (!state.hasMore) return
|
if (!state.hasMore) return
|
||||||
|
|
||||||
|
currentSearchPage += 1
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isManualStop = false,
|
isManualStop = false,
|
||||||
showSuggestions = false,
|
showSuggestions = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
searchSession.search(currentSearchId, state.committedQuery)
|
startSearch(state.committedQuery, currentSearchPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopSearch() {
|
private fun startSearch(keyword: String, page: Int) {
|
||||||
_uiState.update { it.copy(isManualStop = true) }
|
searchJob?.cancel()
|
||||||
searchSession.stop()
|
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) {
|
private fun toggleScopeGroup(groupName: String) {
|
||||||
|
|||||||
@@ -3,30 +3,39 @@ package io.legado.app.web.socket
|
|||||||
import fi.iki.elonen.NanoHTTPD
|
import fi.iki.elonen.NanoHTTPD
|
||||||
import fi.iki.elonen.NanoWSD
|
import fi.iki.elonen.NanoWSD
|
||||||
import io.legado.app.R
|
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.help.config.AppConfig
|
||||||
import io.legado.app.model.webBook.SearchModel
|
import io.legado.app.ui.config.otherConfig.OtherConfig
|
||||||
import io.legado.app.ui.book.search.SearchScope
|
|
||||||
import io.legado.app.utils.GSON
|
import io.legado.app.utils.GSON
|
||||||
import io.legado.app.utils.fromJsonObject
|
import io.legado.app.utils.fromJsonObject
|
||||||
|
import io.legado.app.utils.getPrefBoolean
|
||||||
import io.legado.app.utils.isJson
|
import io.legado.app.utils.isJson
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers.IO
|
import kotlinx.coroutines.Dispatchers.IO
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.MainScope
|
import kotlinx.coroutines.MainScope
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.core.context.GlobalContext
|
||||||
import splitties.init.appCtx
|
import splitties.init.appCtx
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) :
|
class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) :
|
||||||
NanoWSD.WebSocket(handshakeRequest),
|
NanoWSD.WebSocket(handshakeRequest),
|
||||||
CoroutineScope by MainScope(),
|
CoroutineScope by MainScope() {
|
||||||
SearchModel.CallBack {
|
|
||||||
|
|
||||||
private val normalClosure = NanoWSD.WebSocketFrame.CloseCode.NormalClosure
|
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<String>()
|
private val sentBookUrls = linkedSetOf<String>()
|
||||||
|
private var searchJob: Job? = null
|
||||||
|
|
||||||
private val SEARCH_FINISH = "Search finish"
|
private val SEARCH_FINISH = "Search finish"
|
||||||
|
|
||||||
@@ -46,8 +55,8 @@ class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) :
|
|||||||
reason: String,
|
reason: String,
|
||||||
initiatedByRemote: Boolean
|
initiatedByRemote: Boolean
|
||||||
) {
|
) {
|
||||||
|
searchJob?.cancel()
|
||||||
cancel()
|
cancel()
|
||||||
searchModel.close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMessage(message: NanoWSD.WebSocketFrame) {
|
override fun onMessage(message: NanoWSD.WebSocketFrame) {
|
||||||
@@ -61,14 +70,13 @@ class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) :
|
|||||||
val searchMap =
|
val searchMap =
|
||||||
GSON.fromJsonObject<Map<String, String>>(message.textPayload).getOrNull()
|
GSON.fromJsonObject<Map<String, String>>(message.textPayload).getOrNull()
|
||||||
if (searchMap != null) {
|
if (searchMap != null) {
|
||||||
val key = searchMap["key"]
|
val key = searchMap["key"]?.trim()
|
||||||
if (key.isNullOrBlank()) {
|
if (key.isNullOrBlank()) {
|
||||||
send(appCtx.getString(R.string.cannot_empty))
|
send(appCtx.getString(R.string.cannot_empty))
|
||||||
close(normalClosure, SEARCH_FINISH, false)
|
close(normalClosure, SEARCH_FINISH, false)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
sentBookUrls.clear()
|
startSearch(key)
|
||||||
searchModel.search(System.currentTimeMillis(), 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() {
|
is SearchRunEvent.Finished -> close(normalClosure, SEARCH_FINISH, false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (exception: CancellationException) {
|
||||||
override suspend fun onSearchSuccess(
|
throw exception
|
||||||
upsertBooks: List<SearchBook>,
|
} catch (exception: Throwable) {
|
||||||
removedBookUrls: List<String>,
|
close(normalClosure, exception.toString(), false)
|
||||||
resultCount: Int,
|
}
|
||||||
processedSources: Int,
|
|
||||||
totalSources: Int
|
|
||||||
) {
|
|
||||||
val newBooks = upsertBooks.filter { sentBookUrls.add(it.bookUrl) }
|
|
||||||
if (newBooks.isNotEmpty()) {
|
|
||||||
send(GSON.toJson(newBooks))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user