HapeLee 6 месяцев назад
Родитель
Сommit
e1070c745e

+ 25 - 97
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.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 {
-        return SearchSessionImpl(scopeProvider)
-    }
-
-    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 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))
+                }
             }
             }
 
 
-            override suspend fun onSearchFinish(isEmpty: Boolean, hasMore: Boolean) {
-                _events.emit(SearchSessionEvent.Finished(isEmpty, hasMore))
+            if (selectedSources.isEmpty()) {
+                appDb.bookSourceDao.allEnabledPart
+            } else {
+                selectedSources.toList().sortedBy { it.customOrder }
             }
             }
-
-            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 suspend fun getBookSource(sourceUrl: String) = withContext(Dispatchers.IO) {
+        appDb.bookSourceDao.getBookSource(sourceUrl)
+    }
 
 
-        override fun close() {
-            searchModel.close()
-            sessionScope.cancel()
+    override suspend fun saveSearchBooks(books: List<SearchBook>) = withContext(Dispatchers.IO) {
+        if (books.isNotEmpty()) {
+            appDb.searchBookDao.insert(books)
         }
         }
     }
     }
 }
 }

+ 12 - 0
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<BookSourcePart>
+    suspend fun getBookSource(sourceUrl: String): BookSource?
+    suspend fun saveSearchBooks(books: List<SearchBook>)
+}

+ 40 - 0
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<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,
+    )
+}

+ 255 - 0
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<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(),
+    )
+}

+ 0 - 252
app/src/main/java/io/legado/app/model/webBook/SearchModel.kt

@@ -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(),
-    )
-
-}

+ 7 - 0
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.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()) {

+ 99 - 67
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.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.ResumeEngine -> searchSession.resume()
+            SearchIntent.PauseEngine -> searchControl.pause()
+            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 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 stopSearch() {
-        _uiState.update { it.copy(isManualStop = true) }
-        searchSession.stop()
+    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) {

+ 53 - 31
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.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.book.search.SearchScope
+import io.legado.app.ui.config.otherConfig.OtherConfig
 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(),
-    SearchModel.CallBack {
+    CoroutineScope by MainScope() {
 
 
     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()
-                    searchModel.search(System.currentTimeMillis(), key)
+                    startSearch(key)
                 }
                 }
             }
             }
         }
         }
@@ -82,27 +90,41 @@ class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) :
 
 
     }
     }
 
 
-    override fun getSearchScope(): SearchScope = SearchScope(AppConfig.searchScope)
-
-    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))
+    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))
+                                }
+                            }
+
+                            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)
-
 }
 }