[优化] 重写搜索核心
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.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<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(
|
||||
private val appDb: AppDatabase,
|
||||
) : SearchRepository {
|
||||
) : SearchRepository, BookSearchGateway {
|
||||
|
||||
override val enabledGroups: Flow<List<String>> = appDb.bookSourceDao.flowEnabledGroups()
|
||||
override val enabledSources: Flow<List<BookSourcePart>> = 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<BookSourcePart> =
|
||||
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(
|
||||
scopeProvider: () -> SearchScope,
|
||||
) : SearchSession {
|
||||
|
||||
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()
|
||||
override suspend fun saveSearchBooks(books: List<SearchBook>) = withContext(Dispatchers.IO) {
|
||||
if (books.isNotEmpty()) {
|
||||
appDb.searchBookDao.insert(books)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.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()) {
|
||||
|
||||
@@ -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<Set<BookShelfKey>>(emptySet())
|
||||
private val searchScope = SearchScope(AppConfig.searchScope)
|
||||
private val searchSession = repository.createSearchSession { searchScope }
|
||||
private val searchControl = BookSearchControl()
|
||||
private val searchResultBooks = LinkedHashMap<String, SearchBook>()
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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<String>()
|
||||
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<Map<String, String>>(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<SearchBook>,
|
||||
removedBookUrls: List<String>,
|
||||
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)
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user