Parcourir la source

[优化] 依旧优化某些界面的OOM问题

HapeLee il y a 6 mois
Parent
commit
c2e1cc9971

+ 4 - 1
app/src/main/java/io/legado/app/data/dao/BookChapterDao.kt

@@ -35,6 +35,9 @@ interface BookChapterDao {
     @Query("select count(url) from chapters where bookUrl = :bookUrl")
     @Query("select count(url) from chapters where bookUrl = :bookUrl")
     fun getChapterCount(bookUrl: String): Int
     fun getChapterCount(bookUrl: String): Int
 
 
+    @Query("select count(url) from chapters where bookUrl = :bookUrl and isVolume = 1")
+    fun getVolumeCount(bookUrl: String): Int
+
     @Insert(onConflict = OnConflictStrategy.REPLACE)
     @Insert(onConflict = OnConflictStrategy.REPLACE)
     fun insert(vararg bookChapter: BookChapter)
     fun insert(vararg bookChapter: BookChapter)
 
 
@@ -59,4 +62,4 @@ interface BookChapterDao {
         AND `index` = :chapterIndex
         AND `index` = :chapterIndex
     """)
     """)
     suspend fun getChapterTitleByUrlAndIndex(bookUrl: String, chapterIndex: Int): String?
     suspend fun getChapterTitleByUrlAndIndex(bookUrl: String, chapterIndex: Int): String?
-}
+}

+ 4 - 1
app/src/main/java/io/legado/app/data/dao/SearchBookDao.kt

@@ -59,6 +59,9 @@ interface SearchBookDao {
     @Insert(onConflict = OnConflictStrategy.REPLACE)
     @Insert(onConflict = OnConflictStrategy.REPLACE)
     fun insert(vararg searchBook: SearchBook): List<Long>
     fun insert(vararg searchBook: SearchBook): List<Long>
 
 
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    fun insert(searchBooks: List<SearchBook>): List<Long>
+
     @Query("delete from searchBooks where name = :name and author = :author")
     @Query("delete from searchBooks where name = :name and author = :author")
     fun clear(name: String, author: String)
     fun clear(name: String, author: String)
 
 
@@ -70,4 +73,4 @@ interface SearchBookDao {
 
 
     @Delete
     @Delete
     fun delete(vararg searchBook: SearchBook)
     fun delete(vararg searchBook: SearchBook)
-}
+}

+ 16 - 10
app/src/main/java/io/legado/app/data/repository/SearchRepository.kt

@@ -39,7 +39,9 @@ sealed interface SearchSessionEvent {
     data object Started : SearchSessionEvent
     data object Started : SearchSessionEvent
 
 
     data class Progress(
     data class Progress(
-        val books: List<SearchBook>,
+        val upsertBooks: List<SearchBook>,
+        val removedBookUrls: List<String>,
+        val resultCount: Int,
         val processedSources: Int,
         val processedSources: Int,
         val totalSources: Int,
         val totalSources: Int,
     ) : SearchSessionEvent
     ) : SearchSessionEvent
@@ -120,32 +122,36 @@ class SearchRepositoryImpl(
     ) : SearchSession {
     ) : SearchSession {
 
 
         private val sessionScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
         private val sessionScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
-        private val _events = MutableSharedFlow<SearchSessionEvent>(extraBufferCapacity = 64)
+        private val _events = MutableSharedFlow<SearchSessionEvent>(extraBufferCapacity = 8)
         override val events: Flow<SearchSessionEvent> = _events.asSharedFlow()
         override val events: Flow<SearchSessionEvent> = _events.asSharedFlow()
 
 
         private val searchModel = SearchModel(sessionScope, object : SearchModel.CallBack {
         private val searchModel = SearchModel(sessionScope, object : SearchModel.CallBack {
             override fun getSearchScope(): SearchScope = scopeProvider()
             override fun getSearchScope(): SearchScope = scopeProvider()
 
 
-            override fun onSearchStart() {
-                _events.tryEmit(SearchSessionEvent.Started)
+            override suspend fun onSearchStart() {
+                _events.emit(SearchSessionEvent.Started)
             }
             }
 
 
-            override fun onSearchSuccess(
-                searchBooks: List<SearchBook>,
+            override suspend fun onSearchSuccess(
+                upsertBooks: List<SearchBook>,
+                removedBookUrls: List<String>,
+                resultCount: Int,
                 processedSources: Int,
                 processedSources: Int,
                 totalSources: Int,
                 totalSources: Int,
             ) {
             ) {
-                _events.tryEmit(
+                _events.emit(
                     SearchSessionEvent.Progress(
                     SearchSessionEvent.Progress(
-                        books = searchBooks.toList(),
+                        upsertBooks = upsertBooks,
+                        removedBookUrls = removedBookUrls,
+                        resultCount = resultCount,
                         processedSources = processedSources,
                         processedSources = processedSources,
                         totalSources = totalSources,
                         totalSources = totalSources,
                     )
                     )
                 )
                 )
             }
             }
 
 
-            override fun onSearchFinish(isEmpty: Boolean, hasMore: Boolean) {
-                _events.tryEmit(SearchSessionEvent.Finished(isEmpty, hasMore))
+            override suspend fun onSearchFinish(isEmpty: Boolean, hasMore: Boolean) {
+                _events.emit(SearchSessionEvent.Finished(isEmpty, hasMore))
             }
             }
 
 
             override fun onSearchCancel(exception: Throwable?) {
             override fun onSearchCancel(exception: Throwable?) {

+ 21 - 37
app/src/main/java/io/legado/app/domain/usecase/ChangeBookSourceUseCase.kt

@@ -44,7 +44,7 @@ data class BatchChangeBookSourceResult(
 data class BatchChangeSourceCandidate(
 data class BatchChangeSourceCandidate(
     val source: BookSource,
     val source: BookSource,
     val book: Book,
     val book: Book,
-    val chapters: List<BookChapter>,
+    val chapterCount: Int,
 )
 )
 
 
 data class BatchChangeSourcePreviewItem(
 data class BatchChangeSourcePreviewItem(
@@ -172,14 +172,8 @@ class ChangeBookSourceUseCase(
                 } else {
                 } else {
                     val candidates = arrayListOf<BatchChangeSourceCandidate>()
                     val candidates = arrayListOf<BatchChangeSourceCandidate>()
                     sources.filterNot { it.bookSourceUrl == book.origin }.forEach { source ->
                     sources.filterNot { it.bookSourceUrl == book.origin }.forEach { source ->
-                        findBookInSource(book, source)?.let { (newBook, chapters) ->
-                            candidates.add(
-                                BatchChangeSourceCandidate(
-                                    source = source,
-                                    book = newBook,
-                                    chapters = chapters,
-                                )
-                            )
+                        findBookInSource(book, source)?.let { candidate ->
+                            candidates.add(candidate)
                         }
                         }
                     }
                     }
                     if (candidates.isEmpty()) {
                     if (candidates.isEmpty()) {
@@ -199,50 +193,40 @@ class ChangeBookSourceUseCase(
             .map { it.second }
             .map { it.second }
     }
     }
 
 
-    fun changePreviewItems(
-        items: List<BatchChangeSourcePreviewItem>,
-        options: ChangeSourceMigrationOptions,
-    ): BatchChangeBookSourceResult {
-        var changedCount = 0
-        var failedCount = 0
-        var skippedCount = 0
-        items.forEach { item ->
-            if (item.status == BatchChangeSourcePreviewStatus.Skipped) {
-                skippedCount++
-                return@forEach
-            }
-            val candidate = item.selectedCandidate
-            if (candidate == null) {
-                failedCount++
-                return@forEach
-            }
-            changeTo(item.oldBook, candidate.book, candidate.chapters, options)
-            changedCount++
-        }
-        return BatchChangeBookSourceResult(changedCount, failedCount, skippedCount)
-    }
-
     private suspend fun findBookInSource(
     private suspend fun findBookInSource(
         oldBook: Book,
         oldBook: Book,
         source: BookSource,
         source: BookSource,
-    ): Pair<Book, List<BookChapter>>? {
+    ): BatchChangeSourceCandidate? {
         val newBook = WebBook.preciseSearchAwait(source, oldBook.name, oldBook.author)
         val newBook = WebBook.preciseSearchAwait(source, oldBook.name, oldBook.author)
             .onFailure {
             .onFailure {
                 AppLog.put("搜索书籍出错\n${it.localizedMessage}", it, true)
                 AppLog.put("搜索书籍出错\n${it.localizedMessage}", it, true)
             }.getOrNull() ?: return null
             }.getOrNull() ?: return null
+        val chapters = loadCandidateChapters(source, newBook) ?: return null
+        return BatchChangeSourceCandidate(
+            source = source,
+            book = newBook,
+            chapterCount = chapters.size,
+        )
+    }
+
+    suspend fun loadCandidateChapters(
+        source: BookSource,
+        book: Book,
+    ): List<BookChapter>? {
         val infoLoaded = kotlin.runCatching {
         val infoLoaded = kotlin.runCatching {
-            if (newBook.tocUrl.isEmpty()) {
-                WebBook.getBookInfoAwait(source, newBook)
+            if (book.tocUrl.isEmpty()) {
+                WebBook.getBookInfoAwait(source, book)
             }
             }
         }.onFailure {
         }.onFailure {
             AppLog.put("获取书籍详情出错\n${it.localizedMessage}", it, true)
             AppLog.put("获取书籍详情出错\n${it.localizedMessage}", it, true)
         }.isSuccess
         }.isSuccess
         if (!infoLoaded) return null
         if (!infoLoaded) return null
-        val chapters = WebBook.getChapterListAwait(source, newBook)
+        val chapters = WebBook.getChapterListAwait(source, book)
             .onFailure {
             .onFailure {
                 AppLog.put("获取目录出错\n${it.localizedMessage}", it, true)
                 AppLog.put("获取目录出错\n${it.localizedMessage}", it, true)
             }.getOrNull() ?: return null
             }.getOrNull() ?: return null
-        return newBook to chapters
+        book.totalChapterNum = chapters.size
+        return chapters
     }
     }
 
 
     private fun Book.applyMigrationTo(
     private fun Book.applyMigrationTo(

+ 14 - 1
app/src/main/java/io/legado/app/model/CacheBook.kt

@@ -100,6 +100,9 @@ object CacheBook {
         MutableStateFlow<Pair<String, Set<Int>>>("" to emptySet())
         MutableStateFlow<Pair<String, Set<Int>>>("" to emptySet())
     val downloadingIndicesFlow = _downloadingIndicesFlow.asStateFlow()
     val downloadingIndicesFlow = _downloadingIndicesFlow.asStateFlow()
 
 
+    private val _queueChangedFlow = MutableSharedFlow<String>(extraBufferCapacity = 64)
+    val queueChangedFlow = _queueChangedFlow.asSharedFlow()
+
     private val _downloadErrorFlow =
     private val _downloadErrorFlow =
         MutableStateFlow<Pair<String, Set<Int>>>("" to emptySet())
         MutableStateFlow<Pair<String, Set<Int>>>("" to emptySet())
     val downloadErrorFlow = _downloadErrorFlow.asStateFlow()
     val downloadErrorFlow = _downloadErrorFlow.asStateFlow()
@@ -113,6 +116,10 @@ object CacheBook {
     val cacheBookMap: ConcurrentHashMap<String, CacheBookModel>
     val cacheBookMap: ConcurrentHashMap<String, CacheBookModel>
         get() = coordinator.taskMap
         get() = coordinator.taskMap
 
 
+    fun errorIndices(bookUrl: String): Set<Int> {
+        return errorIndexMap[bookUrl]?.toSet().orEmpty()
+    }
+
     private fun collectQueueStats(): QueueStats {
     private fun collectQueueStats(): QueueStats {
         var waiting = 0
         var waiting = 0
         var downloading = 0
         var downloading = 0
@@ -222,6 +229,7 @@ object CacheBook {
 
 
     private fun onTaskQueuesChanged(bookUrl: String) {
     private fun onTaskQueuesChanged(bookUrl: String) {
         updateSummary()
         updateSummary()
+        _queueChangedFlow.tryEmit(bookUrl)
         postEvent(EventBus.UP_DOWNLOAD, bookUrl)
         postEvent(EventBus.UP_DOWNLOAD, bookUrl)
     }
     }
 
 
@@ -297,8 +305,13 @@ object CacheBook {
 
 
         @Synchronized
         @Synchronized
         fun addDownload(start: Int, end: Int) {
         fun addDownload(start: Int, end: Int) {
+            addDownloads(start..end)
+        }
+
+        @Synchronized
+        fun addDownloads(indices: Iterable<Int>) {
             isStopped = false
             isStopped = false
-            for (i in start..end) {
+            for (i in indices) {
                 if (!onDownloadSet.contains(i)) {
                 if (!onDownloadSet.contains(i)) {
                     waitDownloadSet.add(i)
                     waitDownloadSet.add(i)
                 }
                 }

+ 110 - 71
app/src/main/java/io/legado/app/model/webBook/SearchModel.kt

@@ -33,15 +33,22 @@ import kotlin.coroutines.coroutineContext
 import kotlin.math.min
 import kotlin.math.min
 
 
 class SearchModel(private val scope: CoroutineScope, private val callBack: CallBack) {
 class SearchModel(private val scope: CoroutineScope, private val callBack: CallBack) {
+    private companion object {
+        const val MAX_RETAINED_SEARCH_RESULTS = 1000
+    }
+
     val threadCount = OtherConfig.threadCount
     val threadCount = OtherConfig.threadCount
     private var searchPool: ExecutorCoroutineDispatcher? = null
     private var searchPool: ExecutorCoroutineDispatcher? = null
     private var mSearchId = 0L
     private var mSearchId = 0L
     private var searchPage = 1
     private var searchPage = 1
     private var searchKey: String = ""
     private var searchKey: String = ""
     private var bookSourceParts = emptyList<BookSourcePart>()
     private var bookSourceParts = emptyList<BookSourcePart>()
-    private var searchBooks = arrayListOf<SearchBook>()
+    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 searchJob: Job? = null
     private var workingState = MutableStateFlow(true)
     private var workingState = MutableStateFlow(true)
+    private var resultLimitReached = false
 
 
     private fun initSearchPool() {
     private fun initSearchPool() {
         searchPool?.close()
         searchPool?.close()
@@ -58,7 +65,7 @@ class SearchModel(private val scope: CoroutineScope, private val callBack: CallB
             if (mSearchId != 0L) {
             if (mSearchId != 0L) {
                 close()
                 close()
             }
             }
-            searchBooks.clear()
+            clearSearchBooks()
             bookSourceParts = callBack.getSearchScope().getBookSourceParts()
             bookSourceParts = callBack.getSearchScope().getBookSourceParts()
             if (bookSourceParts.isEmpty()) {
             if (bookSourceParts.isEmpty()) {
                 callBack.onSearchCancel(NoStackTraceException("启用书源为空"))
                 callBack.onSearchCancel(NoStackTraceException("启用书源为空"))
@@ -102,84 +109,100 @@ class SearchModel(private val scope: CoroutineScope, private val callBack: CallB
                     book.releaseHtmlData()
                     book.releaseHtmlData()
                 }
                 }
                 hasMore = hasMore || items.isNotEmpty()
                 hasMore = hasMore || items.isNotEmpty()
-                appDb.searchBookDao.insert(*items.toTypedArray())
-                mergeItems(items, precision)
+                if (items.isNotEmpty()) {
+                    appDb.searchBookDao.insert(items)
+                }
+                val change = mergeItems(items, precision)
                 currentCoroutineContext().ensureActive()
                 currentCoroutineContext().ensureActive()
                 processedParts++
                 processedParts++
-                callBack.onSearchSuccess(searchBooks, processedParts, totalParts)
+                callBack.onSearchSuccess(
+                    upsertBooks = change.upsertBooks,
+                    removedBookUrls = change.removedBookUrls,
+                    resultCount = searchBookCount(),
+                    processedSources = processedParts,
+                    totalSources = totalParts,
+                )
             }.onCompletion {
             }.onCompletion {
-                if (it == null) callBack.onSearchFinish(searchBooks.isEmpty(), hasMore)
+                if (it == null) {
+                    callBack.onSearchFinish(
+                        isEmpty = searchBookCount() == 0,
+                        hasMore = hasMore && !resultLimitReached,
+                    )
+                }
             }.catch {
             }.catch {
                 AppLog.put("书源搜索出错\n${it.localizedMessage}", it)
                 AppLog.put("书源搜索出错\n${it.localizedMessage}", it)
             }.collect()
             }.collect()
         }
         }
     }
     }
 
 
-    private suspend fun mergeItems(newDataS: List<SearchBook>, precision: Boolean) {
-        if (newDataS.isNotEmpty()) {
-            val copyData = ArrayList(searchBooks)
-            val equalData = arrayListOf<SearchBook>()
-            val containsData = arrayListOf<SearchBook>()
-            val otherData = arrayListOf<SearchBook>()
-            copyData.forEach {
-                coroutineContext.ensureActive()
-                if (it.name == searchKey || it.author == searchKey) {
-                    equalData.add(it)
-                } else if (it.name.contains(searchKey) || it.author.contains(searchKey)) {
-                    containsData.add(it)
-                } else {
-                    otherData.add(it)
-                }
-            }
-            newDataS.forEach { nBook ->
-                coroutineContext.ensureActive()
-                if (nBook.name == searchKey || nBook.author == searchKey) {
-                    var hasSame = false
-                    equalData.forEach { pBook ->
-                        coroutineContext.ensureActive()
-                        if (pBook.name == nBook.name && pBook.author == nBook.author) {
-                            pBook.addOrigin(nBook.origin)
-                            hasSame = true
-                        }
-                    }
-                    if (!hasSame) {
-                        equalData.add(nBook)
-                    }
-                } else if (nBook.name.contains(searchKey) || nBook.author.contains(searchKey)) {
-                    var hasSame = false
-                    containsData.forEach { pBook ->
-                        coroutineContext.ensureActive()
-                        if (pBook.name == nBook.name && pBook.author == nBook.author) {
-                            pBook.addOrigin(nBook.origin)
-                            hasSame = true
-                        }
-                    }
-                    if (!hasSame) {
-                        containsData.add(nBook)
-                    }
-                } else if (!precision) {
-                    var hasSame = false
-                    otherData.forEach { pBook ->
-                        coroutineContext.ensureActive()
-                        if (pBook.name == nBook.name && pBook.author == nBook.author) {
-                            pBook.addOrigin(nBook.origin)
-                            hasSame = true
-                        }
-                    }
-                    if (!hasSame) {
-                        otherData.add(nBook)
-                    }
-                }
-            }
+    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()
             coroutineContext.ensureActive()
-            equalData.sortByDescending { it.origins.size }
-            equalData.addAll(containsData.sortedByDescending { it.origins.size })
-            if (!precision) {
-                equalData.addAll(otherData)
+            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 }
             }
             }
-            coroutineContext.ensureActive()
-            searchBooks = equalData
         }
         }
+        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() {
     fun cancelSearch() {
@@ -204,10 +227,26 @@ class SearchModel(private val scope: CoroutineScope, private val callBack: CallB
 
 
     interface CallBack {
     interface CallBack {
         fun getSearchScope(): SearchScope
         fun getSearchScope(): SearchScope
-        fun onSearchStart()
-        fun onSearchSuccess(searchBooks: List<SearchBook>, processedSources: Int, totalSources: Int)
-        fun onSearchFinish(isEmpty: Boolean, hasMore: Boolean)
+        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)
         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(),
+    )
+
 }
 }

+ 2 - 4
app/src/main/java/io/legado/app/service/CacheBookService.kt

@@ -163,10 +163,8 @@ class CacheBookService : BaseService() {
                 }
                 }
             }
             }
 
 
-            //添加每一章到下载队列
-            indices.forEach { index ->
-                cacheBook.addDownload(index)
-            }
+            //添加章节到下载队列
+            cacheBook.addDownloads(indices)
 
 
             notificationContent = CacheBook.downloadSummary
             notificationContent = CacheBook.downloadSummary
             upCacheBookNotification()
             upCacheBookNotification()

+ 9 - 12
app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageScreen.kt

@@ -31,7 +31,6 @@ import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.remember
-import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.Modifier
@@ -94,10 +93,9 @@ private fun BookCacheManageScreen(
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
     var pendingDeleteBook by remember { mutableStateOf<BookCacheBookItem?>(null) }
     var pendingDeleteBook by remember { mutableStateOf<BookCacheBookItem?>(null) }
     var pendingDeleteChapter by remember { mutableStateOf<Pair<BookCacheBookItem, BookCacheChapterItem>?>(null) }
     var pendingDeleteChapter by remember { mutableStateOf<Pair<BookCacheBookItem, BookCacheChapterItem>?>(null) }
-    var expandedBookUrls by rememberSaveable { mutableStateOf(emptySet<String>()) }
     val allBooks = state.shelfBooks + state.notShelfBooks
     val allBooks = state.shelfBooks + state.notShelfBooks
     val hasRunningDownload = allBooks.any { it.isDownloading }
     val hasRunningDownload = allBooks.any { it.isDownloading }
-    val hasDownloadTarget = allBooks.any { it.chapters.any { chapter -> !chapter.isCached } }
+    val hasDownloadTarget = allBooks.any { it.cachedCount < it.totalCount }
 
 
     AppScaffold(
     AppScaffold(
         modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
         modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
@@ -160,9 +158,10 @@ private fun BookCacheManageScreen(
                     title = "书架书籍",
                     title = "书架书籍",
                     emptyText = "没有书架内书籍缓存或下载任务",
                     emptyText = "没有书架内书籍缓存或下载任务",
                     books = state.shelfBooks,
                     books = state.shelfBooks,
-                    expandedBookUrls = expandedBookUrls,
+                    expandedBookUrls = state.expandedBookUrls,
+                    chaptersByBookUrl = state.chaptersByBookUrl,
                     onToggleExpanded = { bookUrl ->
                     onToggleExpanded = { bookUrl ->
-                        expandedBookUrls = expandedBookUrls.toggle(bookUrl)
+                        onIntent(BookCacheManageIntent.ToggleBookExpanded(bookUrl))
                     },
                     },
                     onIntent = onIntent,
                     onIntent = onIntent,
                     onDeleteBook = { pendingDeleteBook = it },
                     onDeleteBook = { pendingDeleteBook = it },
@@ -172,9 +171,10 @@ private fun BookCacheManageScreen(
                     title = "未在书架",
                     title = "未在书架",
                     emptyText = "没有未在书架的书籍下载状态",
                     emptyText = "没有未在书架的书籍下载状态",
                     books = state.notShelfBooks,
                     books = state.notShelfBooks,
-                    expandedBookUrls = expandedBookUrls,
+                    expandedBookUrls = state.expandedBookUrls,
+                    chaptersByBookUrl = state.chaptersByBookUrl,
                     onToggleExpanded = { bookUrl ->
                     onToggleExpanded = { bookUrl ->
-                        expandedBookUrls = expandedBookUrls.toggle(bookUrl)
+                        onIntent(BookCacheManageIntent.ToggleBookExpanded(bookUrl))
                     },
                     },
                     onIntent = onIntent,
                     onIntent = onIntent,
                     onDeleteBook = { pendingDeleteBook = it },
                     onDeleteBook = { pendingDeleteBook = it },
@@ -212,6 +212,7 @@ private fun LazyListScope.cacheSection(
     emptyText: String,
     emptyText: String,
     books: List<BookCacheBookItem>,
     books: List<BookCacheBookItem>,
     expandedBookUrls: Set<String>,
     expandedBookUrls: Set<String>,
+    chaptersByBookUrl: Map<String, List<BookCacheChapterItem>>,
     onToggleExpanded: (String) -> Unit,
     onToggleExpanded: (String) -> Unit,
     onIntent: (BookCacheManageIntent) -> Unit,
     onIntent: (BookCacheManageIntent) -> Unit,
     onDeleteBook: (BookCacheBookItem) -> Unit,
     onDeleteBook: (BookCacheBookItem) -> Unit,
@@ -245,7 +246,7 @@ private fun LazyListScope.cacheSection(
             }
             }
             if (expanded) {
             if (expanded) {
                 items(
                 items(
-                    items = item.chapters,
+                    items = chaptersByBookUrl[bookUrl].orEmpty(),
                     key = { chapter -> "$title-chapter-$bookUrl-${chapter.chapter.url}" }
                     key = { chapter -> "$title-chapter-$bookUrl-${chapter.chapter.url}" }
                 ) { chapter ->
                 ) { chapter ->
                     BookCacheChapterRow(
                     BookCacheChapterRow(
@@ -392,10 +393,6 @@ private fun BookCacheChapterRow(
     }
     }
 }
 }
 
 
-private fun Set<String>.toggle(value: String): Set<String> {
-    return if (contains(value)) this - value else this + value
-}
-
 private fun chapterStatusText(item: BookCacheChapterItem): String {
 private fun chapterStatusText(item: BookCacheChapterItem): String {
     return when {
     return when {
         item.isDownloading -> "下载中"
         item.isDownloading -> "下载中"

+ 243 - 61
app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageViewModel.kt

@@ -16,6 +16,8 @@ import io.legado.app.help.book.isNotShelf
 import io.legado.app.model.CacheBook
 import io.legado.app.model.CacheBook
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.Job
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.delay
 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
@@ -23,25 +25,27 @@ import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import kotlinx.coroutines.withContext
+import kotlin.math.min
 
 
 data class BookCacheManageUiState(
 data class BookCacheManageUiState(
     val isLoading: Boolean = true,
     val isLoading: Boolean = true,
     val shelfBooks: List<BookCacheBookItem> = emptyList(),
     val shelfBooks: List<BookCacheBookItem> = emptyList(),
     val notShelfBooks: List<BookCacheBookItem> = emptyList(),
     val notShelfBooks: List<BookCacheBookItem> = emptyList(),
+    val expandedBookUrls: Set<String> = emptySet(),
+    val chaptersByBookUrl: Map<String, List<BookCacheChapterItem>> = emptyMap(),
     val downloadSummary: String = CacheBook.downloadSummary,
     val downloadSummary: String = CacheBook.downloadSummary,
     val version: Long = 0,
     val version: Long = 0,
 )
 )
 
 
 data class BookCacheBookItem(
 data class BookCacheBookItem(
     val book: Book,
     val book: Book,
-    val chapters: List<BookCacheChapterItem>,
+    val totalCount: Int,
     val cachedCount: Int,
     val cachedCount: Int,
     val waitingCount: Int,
     val waitingCount: Int,
     val downloadingCount: Int,
     val downloadingCount: Int,
     val errorCount: Int,
     val errorCount: Int,
     val isNotShelf: Boolean,
     val isNotShelf: Boolean,
 ) {
 ) {
-    val totalCount: Int get() = chapters.size
     val progress: Float get() = if (totalCount == 0) 0f else cachedCount.toFloat() / totalCount
     val progress: Float get() = if (totalCount == 0) 0f else cachedCount.toFloat() / totalCount
     val isDownloading: Boolean get() = waitingCount > 0 || downloadingCount > 0
     val isDownloading: Boolean get() = waitingCount > 0 || downloadingCount > 0
 }
 }
@@ -59,6 +63,7 @@ sealed interface BookCacheManageIntent {
     data object Refresh : BookCacheManageIntent
     data object Refresh : BookCacheManageIntent
     data object StartAllDownloads : BookCacheManageIntent
     data object StartAllDownloads : BookCacheManageIntent
     data object StopAllDownloads : BookCacheManageIntent
     data object StopAllDownloads : BookCacheManageIntent
+    data class ToggleBookExpanded(val bookUrl: String) : BookCacheManageIntent
     data class DeleteBookCache(val bookUrl: String) : BookCacheManageIntent
     data class DeleteBookCache(val bookUrl: String) : BookCacheManageIntent
     data class DownloadChapter(val bookUrl: String, val chapterIndex: Int) : BookCacheManageIntent
     data class DownloadChapter(val bookUrl: String, val chapterIndex: Int) : BookCacheManageIntent
     data class DeleteChapterCache(val bookUrl: String, val chapterUrl: String) : BookCacheManageIntent
     data class DeleteChapterCache(val bookUrl: String, val chapterUrl: String) : BookCacheManageIntent
@@ -83,14 +88,16 @@ class BookCacheManageViewModel(
     val effects = _effects.asSharedFlow()
     val effects = _effects.asSharedFlow()
 
 
     private var observeJob: Job? = null
     private var observeJob: Job? = null
-    private val chapterCache = hashMapOf<String, List<BookChapter>>()
+    private var fullReloadJob: Job? = null
+    private val bookReloadJobs = hashMapOf<String, Job>()
 
 
     fun onIntent(intent: BookCacheManageIntent) {
     fun onIntent(intent: BookCacheManageIntent) {
         when (intent) {
         when (intent) {
             BookCacheManageIntent.Initialize -> initialize()
             BookCacheManageIntent.Initialize -> initialize()
-            BookCacheManageIntent.Refresh -> reload()
+            BookCacheManageIntent.Refresh -> reloadAll(forceDatabase = true)
             BookCacheManageIntent.StartAllDownloads -> startAllDownloads()
             BookCacheManageIntent.StartAllDownloads -> startAllDownloads()
             BookCacheManageIntent.StopAllDownloads -> stopAllDownloads()
             BookCacheManageIntent.StopAllDownloads -> stopAllDownloads()
+            is BookCacheManageIntent.ToggleBookExpanded -> toggleBookExpanded(intent.bookUrl)
             is BookCacheManageIntent.DeleteBookCache -> deleteBookCache(intent.bookUrl)
             is BookCacheManageIntent.DeleteBookCache -> deleteBookCache(intent.bookUrl)
             is BookCacheManageIntent.DownloadChapter -> downloadChapter(
             is BookCacheManageIntent.DownloadChapter -> downloadChapter(
                 intent.bookUrl,
                 intent.bookUrl,
@@ -106,23 +113,28 @@ class BookCacheManageViewModel(
     private fun initialize() {
     private fun initialize() {
         if (observeJob != null) return
         if (observeJob != null) return
         observeJob = viewModelScope.launch {
         observeJob = viewModelScope.launch {
-            bookDao.flowAll().collect {
-                reload(it)
+            bookDao.flowAll().collect { books ->
+                reloadAll(books = books, forceDatabase = false)
             }
             }
         }
         }
         viewModelScope.launch {
         viewModelScope.launch {
-            CacheBook.cacheSuccessFlow.collect {
-                reload()
+            CacheBook.cacheSuccessFlow.collect { chapter ->
+                scheduleBookReload(chapter.bookUrl)
             }
             }
         }
         }
         viewModelScope.launch {
         viewModelScope.launch {
-            CacheBook.downloadingIndicesFlow.collect {
-                reload()
+            CacheBook.downloadingIndicesFlow.collect { state ->
+                scheduleBookReload(state.first)
             }
             }
         }
         }
         viewModelScope.launch {
         viewModelScope.launch {
-            CacheBook.downloadErrorFlow.collect {
-                reload()
+            CacheBook.queueChangedFlow.collect { bookUrl ->
+                scheduleBookReload(bookUrl)
+            }
+        }
+        viewModelScope.launch {
+            CacheBook.downloadErrorFlow.collect { state ->
+                scheduleBookReload(state.first)
             }
             }
         }
         }
         viewModelScope.launch {
         viewModelScope.launch {
@@ -130,53 +142,146 @@ class BookCacheManageViewModel(
                 _uiState.update {
                 _uiState.update {
                     it.copy(downloadSummary = summary, version = it.version + 1)
                     it.copy(downloadSummary = summary, version = it.version + 1)
                 }
                 }
-                reload()
             }
             }
         }
         }
     }
     }
 
 
-    private fun reload(books: List<Book>? = null) {
-        viewModelScope.launch(Dispatchers.IO) {
-            val sourceBooks = books ?: bookDao.all
-            val items = sourceBooks
-                .filterNot { it.isLocal || it.isAudio }
-                .mapNotNull { book -> buildBookItem(book) }
-                .filter { item ->
-                    item.cachedCount > 0 || item.isDownloading || item.errorCount > 0
+    private fun reloadAll(
+        books: List<Book>? = null,
+        forceDatabase: Boolean = false,
+    ) {
+        fullReloadJob?.cancel()
+        bookReloadJobs.values.forEach { it.cancel() }
+        bookReloadJobs.clear()
+        fullReloadJob = viewModelScope.launch {
+            val expandedBookUrls = uiState.value.expandedBookUrls
+            val result = withContext(Dispatchers.IO) {
+                val sourceBooks = if (forceDatabase) bookDao.all else books ?: bookDao.all
+                val items = sortItems(
+                    sourceBooks
+                        .filterNot { it.isLocal || it.isAudio }
+                        .mapNotNull { book -> buildBookItem(book) }
+                        .filter { item ->
+                            item.cachedCount > 0 || item.isDownloading || item.errorCount > 0
+                        }
+                )
+                val booksByUrl = items.associateBy { it.book.bookUrl }
+                val retainedExpandedBookUrls = expandedBookUrls.filterTo(linkedSetOf()) {
+                    booksByUrl.containsKey(it)
                 }
                 }
-                .sortedWith(compareByDescending<BookCacheBookItem> { it.isDownloading }
-                    .thenByDescending { it.cachedCount }
-                    .thenBy { it.book.name })
-            withContext(Dispatchers.Main) {
-                _uiState.update {
-                    it.copy(
-                        isLoading = false,
-                        shelfBooks = items.filterNot { item -> item.isNotShelf },
-                        notShelfBooks = items.filter { item -> item.isNotShelf },
-                        downloadSummary = CacheBook.downloadSummary,
-                        version = it.version + 1,
-                    )
+                val chaptersByBookUrl = retainedExpandedBookUrls.associateWith { bookUrl ->
+                    buildChapterItems(booksByUrl.getValue(bookUrl).book)
                 }
                 }
+                LoadedCacheState(
+                    items = items,
+                    expandedBookUrls = retainedExpandedBookUrls,
+                    chaptersByBookUrl = chaptersByBookUrl,
+                )
+            }
+            _uiState.update { state ->
+                state.copy(
+                    isLoading = false,
+                    shelfBooks = result.items.filterNot { item -> item.isNotShelf },
+                    notShelfBooks = result.items.filter { item -> item.isNotShelf },
+                    expandedBookUrls = result.expandedBookUrls,
+                    chaptersByBookUrl = result.chaptersByBookUrl,
+                    downloadSummary = CacheBook.downloadSummary,
+                    version = state.version + 1,
+                )
             }
             }
         }
         }
     }
     }
 
 
-    private fun buildBookItem(book: Book): BookCacheBookItem? {
-        val chapters = chapterCache.getOrPut(book.bookUrl) {
-            bookChapterDao.getChapterList(book.bookUrl)
+    private fun scheduleBookReload(bookUrl: String, debounceMillis: Long = 80) {
+        if (bookUrl.isBlank()) return
+        bookReloadJobs.remove(bookUrl)?.cancel()
+        bookReloadJobs[bookUrl] = viewModelScope.launch {
+            if (debounceMillis > 0) {
+                delay(debounceMillis)
+            }
+            reloadBook(bookUrl)
+            if (bookReloadJobs[bookUrl] == currentCoroutineContext()[Job]) {
+                bookReloadJobs.remove(bookUrl)
+            }
+        }
+    }
+
+    private suspend fun reloadBook(bookUrl: String) {
+        val expanded = uiState.value.expandedBookUrls.contains(bookUrl)
+        val result = withContext(Dispatchers.IO) {
+            val book = bookDao.getBook(bookUrl)
+            val item = book
+                ?.takeUnless { it.isLocal || it.isAudio }
+                ?.let { buildBookItem(it) }
+                ?.takeIf {
+                    it.cachedCount > 0 || it.isDownloading || it.errorCount > 0
+                }
+            val chapters = if (expanded && item != null) {
+                buildChapterItems(item.book)
+            } else {
+                null
+            }
+            LoadedBookState(item, chapters)
+        }
+        _uiState.update { state ->
+            val combinedBooks = (state.shelfBooks + state.notShelfBooks)
+                .filterNot { it.book.bookUrl == bookUrl }
+                .let { items ->
+                    result.item?.let { items + it } ?: items
+                }
+            val sortedBooks = sortItems(combinedBooks)
+            val expandedBookUrls = if (result.item == null) {
+                state.expandedBookUrls - bookUrl
+            } else {
+                state.expandedBookUrls
+            }
+            val chaptersByBookUrl = when {
+                result.item == null -> state.chaptersByBookUrl - bookUrl
+                result.chapters != null -> state.chaptersByBookUrl + (bookUrl to result.chapters)
+                else -> state.chaptersByBookUrl
+            }
+            state.copy(
+                shelfBooks = sortedBooks.filterNot { item -> item.isNotShelf },
+                notShelfBooks = sortedBooks.filter { item -> item.isNotShelf },
+                expandedBookUrls = expandedBookUrls,
+                chaptersByBookUrl = chaptersByBookUrl,
+                downloadSummary = CacheBook.downloadSummary,
+                version = state.version + 1,
+            )
         }
         }
+    }
+
+    private fun buildBookItem(book: Book): BookCacheBookItem? {
         val cacheFiles = BookHelp.getChapterFiles(book)
         val cacheFiles = BookHelp.getChapterFiles(book)
         val model = CacheBook.cacheBookMap[book.bookUrl]
         val model = CacheBook.cacheBookMap[book.bookUrl]
         val waitingIndices = model?.waitingIndices().orEmpty()
         val waitingIndices = model?.waitingIndices().orEmpty()
         val downloadingIndices = model?.downloadingIndices().orEmpty()
         val downloadingIndices = model?.downloadingIndices().orEmpty()
-        val errorIndices = CacheBook.downloadErrorFlow.value
-            .takeIf { it.first == book.bookUrl }
-            ?.second
-            .orEmpty()
-        if (chapters.isEmpty() && cacheFiles.isEmpty() && model == null && !book.isNotShelf) {
+        val errorIndices = errorIndices(book.bookUrl)
+        val totalCount = bookChapterDao.getChapterCount(book.bookUrl)
+        val cachedFileCount = cacheFiles.count { it.endsWith(".nb") }
+        val cachedCount = min(cachedFileCount + bookChapterDao.getVolumeCount(book.bookUrl), totalCount)
+        if (totalCount == 0 && cacheFiles.isEmpty() && model == null && !book.isNotShelf) {
             return null
             return null
         }
         }
-        val chapterItems = chapters.map { chapter ->
+        return BookCacheBookItem(
+            book = book,
+            totalCount = totalCount,
+            cachedCount = cachedCount,
+            waitingCount = waitingIndices.size,
+            downloadingCount = downloadingIndices.size,
+            errorCount = errorIndices.size,
+            isNotShelf = book.isNotShelf,
+        )
+    }
+
+    private fun buildChapterItems(book: Book): List<BookCacheChapterItem> {
+        val chapters = bookChapterDao.getChapterList(book.bookUrl)
+        val cacheFiles = BookHelp.getChapterFiles(book)
+        val model = CacheBook.cacheBookMap[book.bookUrl]
+        val waitingIndices = model?.waitingIndices().orEmpty()
+        val downloadingIndices = model?.downloadingIndices().orEmpty()
+        val errorIndices = errorIndices(book.bookUrl)
+        return chapters.map { chapter ->
             BookCacheChapterItem(
             BookCacheChapterItem(
                 chapter = chapter,
                 chapter = chapter,
                 isCached = cacheFiles.contains(chapter.getFileName()) || chapter.isVolume,
                 isCached = cacheFiles.contains(chapter.getFileName()) || chapter.isVolume,
@@ -185,29 +290,68 @@ class BookCacheManageViewModel(
                 isError = errorIndices.contains(chapter.index),
                 isError = errorIndices.contains(chapter.index),
             )
             )
         }
         }
-        return BookCacheBookItem(
-            book = book,
-            chapters = chapterItems,
-            cachedCount = chapterItems.count { it.isCached },
-            waitingCount = waitingIndices.size,
-            downloadingCount = downloadingIndices.size,
-            errorCount = errorIndices.size,
-            isNotShelf = book.isNotShelf,
-        )
+    }
+
+    private fun errorIndices(bookUrl: String): Set<Int> {
+        return CacheBook.errorIndices(bookUrl)
+    }
+
+    private fun toggleBookExpanded(bookUrl: String) {
+        val shouldExpand = !_uiState.value.expandedBookUrls.contains(bookUrl)
+        _uiState.update { state ->
+            if (shouldExpand) {
+                state.copy(
+                    expandedBookUrls = state.expandedBookUrls + bookUrl,
+                    version = state.version + 1,
+                )
+            } else {
+                state.copy(
+                    expandedBookUrls = state.expandedBookUrls - bookUrl,
+                    chaptersByBookUrl = state.chaptersByBookUrl - bookUrl,
+                    version = state.version + 1,
+                )
+            }
+        }
+        if (shouldExpand) {
+            loadBookChapters(bookUrl)
+        }
+    }
+
+    private fun loadBookChapters(bookUrl: String) {
+        viewModelScope.launch {
+            val chapters = withContext(Dispatchers.IO) {
+                val book = findBook(bookUrl) ?: bookDao.getBook(bookUrl) ?: return@withContext null
+                buildChapterItems(book)
+            } ?: return@launch
+            _uiState.update { state ->
+                if (!state.expandedBookUrls.contains(bookUrl)) {
+                    state
+                } else {
+                    state.copy(
+                        chaptersByBookUrl = state.chaptersByBookUrl + (bookUrl to chapters),
+                        version = state.version + 1,
+                    )
+                }
+            }
+        }
+    }
+
+    private fun findBook(bookUrl: String): Book? {
+        return (uiState.value.shelfBooks + uiState.value.notShelfBooks)
+            .firstOrNull { it.book.bookUrl == bookUrl }
+            ?.book
     }
     }
 
 
     private fun stopAllDownloads() {
     private fun stopAllDownloads() {
         CacheBook.stop(context)
         CacheBook.stop(context)
-        reload()
+        reloadAll(forceDatabase = true)
     }
     }
 
 
     private fun startAllDownloads() {
     private fun startAllDownloads() {
         val items = uiState.value.shelfBooks + uiState.value.notShelfBooks
         val items = uiState.value.shelfBooks + uiState.value.notShelfBooks
         execute {
         execute {
             items.sumOf { item ->
             items.sumOf { item ->
-                val chapterIndices = item.chapters
-                    .filterNot { it.isCached || it.isWaiting || it.isDownloading }
-                    .map { it.chapter.index }
+                val chapterIndices = downloadableChapterIndices(item.book)
                 cacheBookChaptersUseCase.execute(item.book.bookUrl, chapterIndices)
                 cacheBookChaptersUseCase.execute(item.book.bookUrl, chapterIndices)
             }
             }
         }.onSuccess { count ->
         }.onSuccess { count ->
@@ -219,10 +363,27 @@ class BookCacheManageViewModel(
         }.onError {
         }.onError {
             _effects.tryEmit(BookCacheManageEffect.ShowMessage("加入缓存队列失败\n${it.localizedMessage}"))
             _effects.tryEmit(BookCacheManageEffect.ShowMessage("加入缓存队列失败\n${it.localizedMessage}"))
         }.onFinally {
         }.onFinally {
-            reload()
+            reloadAll(forceDatabase = true)
         }
         }
     }
     }
 
 
+    private fun downloadableChapterIndices(book: Book): List<Int> {
+        val cacheFiles = BookHelp.getChapterFiles(book)
+        val model = CacheBook.cacheBookMap[book.bookUrl]
+        val waitingIndices = model?.waitingIndices().orEmpty()
+        val downloadingIndices = model?.downloadingIndices().orEmpty()
+        return bookChapterDao.getChapterList(book.bookUrl)
+            .asSequence()
+            .filterNot { chapter ->
+                chapter.isVolume ||
+                    cacheFiles.contains(chapter.getFileName()) ||
+                    waitingIndices.contains(chapter.index) ||
+                    downloadingIndices.contains(chapter.index)
+            }
+            .map { it.index }
+            .toList()
+    }
+
     private fun deleteBookCache(bookUrl: String) {
     private fun deleteBookCache(bookUrl: String) {
         CacheBook.remove(context, bookUrl)
         CacheBook.remove(context, bookUrl)
         execute {
         execute {
@@ -232,7 +393,7 @@ class BookCacheManageViewModel(
         }.onError {
         }.onError {
             _effects.tryEmit(BookCacheManageEffect.ShowMessage("删除缓存失败\n${it.localizedMessage}"))
             _effects.tryEmit(BookCacheManageEffect.ShowMessage("删除缓存失败\n${it.localizedMessage}"))
         }.onFinally {
         }.onFinally {
-            reload()
+            scheduleBookReload(bookUrl, debounceMillis = 0)
         }
         }
     }
     }
 
 
@@ -244,13 +405,17 @@ class BookCacheManageViewModel(
         }.onError {
         }.onError {
             _effects.tryEmit(BookCacheManageEffect.ShowMessage("章节缓存失败\n${it.localizedMessage}"))
             _effects.tryEmit(BookCacheManageEffect.ShowMessage("章节缓存失败\n${it.localizedMessage}"))
         }.onFinally {
         }.onFinally {
-            reload()
+            scheduleBookReload(bookUrl, debounceMillis = 0)
         }
         }
     }
     }
 
 
     private fun deleteChapterCache(bookUrl: String, chapterUrl: String) {
     private fun deleteChapterCache(bookUrl: String, chapterUrl: String) {
-        val book = bookDao.getBook(bookUrl) ?: return
-        val chapter = chapterCache[bookUrl]?.firstOrNull { it.url == chapterUrl } ?: return
+        val book = findBook(bookUrl) ?: bookDao.getBook(bookUrl) ?: return
+        val chapter = uiState.value.chaptersByBookUrl[bookUrl]
+            ?.firstOrNull { it.chapter.url == chapterUrl }
+            ?.chapter
+            ?: bookChapterDao.getChapterList(bookUrl).firstOrNull { it.url == chapterUrl }
+            ?: return
         execute {
         execute {
             BookHelp.delContent(book, chapter)
             BookHelp.delContent(book, chapter)
         }.onSuccess {
         }.onSuccess {
@@ -258,7 +423,24 @@ class BookCacheManageViewModel(
         }.onError {
         }.onError {
             _effects.tryEmit(BookCacheManageEffect.ShowMessage("删除章节缓存失败\n${it.localizedMessage}"))
             _effects.tryEmit(BookCacheManageEffect.ShowMessage("删除章节缓存失败\n${it.localizedMessage}"))
         }.onFinally {
         }.onFinally {
-            reload()
+            scheduleBookReload(bookUrl, debounceMillis = 0)
         }
         }
     }
     }
+
+    private fun sortItems(items: List<BookCacheBookItem>): List<BookCacheBookItem> {
+        return items.sortedWith(compareByDescending<BookCacheBookItem> { it.isDownloading }
+            .thenByDescending { it.cachedCount }
+            .thenBy { it.book.name })
+    }
+
+    private data class LoadedCacheState(
+        val items: List<BookCacheBookItem>,
+        val expandedBookUrls: Set<String>,
+        val chaptersByBookUrl: Map<String, List<BookCacheChapterItem>>,
+    )
+
+    private data class LoadedBookState(
+        val item: BookCacheBookItem?,
+        val chapters: List<BookCacheChapterItem>?,
+    )
 }
 }

+ 3 - 3
app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt

@@ -684,7 +684,7 @@ private fun BookshelfManageScreen(
         ) {
         ) {
             items(filteredBooks, key = { it.bookUrl }) { book ->
             items(filteredBooks, key = { it.bookUrl }) { book ->
                 val cacheCount = remember(renderVersion, book.bookUrl) {
                 val cacheCount = remember(renderVersion, book.bookUrl) {
-                    viewModel.getCacheChapters(book.bookUrl)?.size ?: 0
+                    viewModel.getCacheCount(book.bookUrl) ?: 0
                 }
                 }
                 val isDownloading = remember(renderVersion, book.bookUrl) {
                 val isDownloading = remember(renderVersion, book.bookUrl) {
                     viewModel.isBookDownloading(book.bookUrl)
                     viewModel.isBookDownloading(book.bookUrl)
@@ -1512,7 +1512,7 @@ private fun BatchChangePreviewRow(
                     chapterCount = if (item.status == BatchChangeSourcePreviewStatus.Skipped) {
                     chapterCount = if (item.status == BatchChangeSourcePreviewStatus.Skipped) {
                         null
                         null
                     } else {
                     } else {
-                        candidate?.chapters?.size
+                        candidate?.chapterCount
                     },
                     },
                     onClick = {
                     onClick = {
                         candidate?.book?.let { onOpenBook(it, false) }
                         candidate?.book?.let { onOpenBook(it, false) }
@@ -1624,7 +1624,7 @@ private fun OtherSourceOptionsSheet(
                     val candidate = currentItem.candidates[index]
                     val candidate = currentItem.candidates[index]
                     SelectionItemCard(
                     SelectionItemCard(
                         title = candidate.source.bookSourceName,
                         title = candidate.source.bookSourceName,
-                        subtitle = "${candidate.book.name} · ${candidate.chapters.size}章",
+                        subtitle = "${candidate.book.name} · ${candidate.chapterCount}章",
                         supportingContent = {
                         supportingContent = {
                             AppText(
                             AppText(
                                 text = candidate.book.getRealAuthor(),
                                 text = candidate.book.getRealAuthor(),

+ 74 - 35
app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreenViewModel.kt

@@ -35,6 +35,7 @@ import io.legado.app.ui.config.otherConfig.OtherConfig
 import io.legado.app.ui.main.bookshelf.toLightBook
 import io.legado.app.ui.main.bookshelf.toLightBook
 import io.legado.app.utils.cnCompare
 import io.legado.app.utils.cnCompare
 import io.legado.app.utils.move
 import io.legado.app.utils.move
+import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.ensureActive
 import kotlinx.coroutines.ensureActive
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableSharedFlow
@@ -45,6 +46,8 @@ import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
 import kotlin.math.max
 import kotlin.math.max
+import kotlin.math.min
+import java.util.concurrent.ConcurrentHashMap
 
 
 data class BookshelfManageScreenExportConfig(
 data class BookshelfManageScreenExportConfig(
     val exportUseReplace: Boolean = true,
     val exportUseReplace: Boolean = true,
@@ -158,9 +161,11 @@ class BookshelfManageScreenViewModel(
     private val _effects = MutableSharedFlow<BookshelfManageScreenEffect>(extraBufferCapacity = 32)
     private val _effects = MutableSharedFlow<BookshelfManageScreenEffect>(extraBufferCapacity = 32)
     val effects = _effects.asSharedFlow()
     val effects = _effects.asSharedFlow()
 
 
-    private val cacheChapters = hashMapOf<String, HashSet<String>>()
+    private val cacheCounts = ConcurrentHashMap<String, Int>()
     private var booksJob: Job? = null
     private var booksJob: Job? = null
     private var groupsJob: Job? = null
     private var groupsJob: Job? = null
+    private var cacheLoadJob: Job? = null
+    private val cacheCountJobs = ConcurrentHashMap<String, Job>()
     private var observersStarted = false
     private var observersStarted = false
 
 
     fun dispatch(intent: BookshelfManageScreenIntent) {
     fun dispatch(intent: BookshelfManageScreenIntent) {
@@ -285,9 +290,7 @@ class BookshelfManageScreenViewModel(
         }
         }
     }
     }
 
 
-    fun getCacheChapters(bookUrl: String): Set<String>? = cacheChapters[bookUrl]
-
-    fun getAllCacheChapters(): Map<String, Set<String>> = cacheChapters
+    fun getCacheCount(bookUrl: String): Int? = cacheCounts[bookUrl]
 
 
     fun isBookDownloading(bookUrl: String): Boolean {
     fun isBookDownloading(bookUrl: String): Boolean {
         return CacheBook.cacheBookMap[bookUrl]?.isStop() == false
         return CacheBook.cacheBookMap[bookUrl]?.isStop() == false
@@ -354,7 +357,7 @@ class BookshelfManageScreenViewModel(
                         bookSortOrder = bookshelfManageScreenConfig.bookshelfSortOrder
                         bookSortOrder = bookshelfManageScreenConfig.bookshelfSortOrder
                     )
                     )
                 }
                 }
-                loadCacheFiles(books)
+                loadCacheCounts(books)
             }
             }
         }
         }
     }
     }
@@ -427,22 +430,20 @@ class BookshelfManageScreenViewModel(
         }
         }
     }
     }
 
 
-    private fun loadCacheFiles(books: List<Book>) {
-        execute {
+    private fun loadCacheCounts(books: List<Book>) {
+        cacheLoadJob?.cancel()
+        cacheCountJobs.values.forEach { it.cancel() }
+        cacheCountJobs.clear()
+        cacheLoadJob = viewModelScope.launch(Dispatchers.IO) {
+            val visibleBookUrls = books.mapTo(hashSetOf()) { it.bookUrl }
+            cacheCounts.keys.toList().forEach { bookUrl ->
+                if (!visibleBookUrls.contains(bookUrl)) {
+                    cacheCounts.remove(bookUrl)
+                }
+            }
             books.forEach { book ->
             books.forEach { book ->
-                if (!book.isLocal && !cacheChapters.contains(book.bookUrl)) {
-                    val chapterCaches = hashSetOf<String>()
-                    val cacheNames = BookHelp.getChapterFiles(book)
-                    if (cacheNames.isNotEmpty()) {
-                        bookChapterDao.getChapterList(book.bookUrl).also {
-                            book.totalChapterNum = it.size
-                        }.forEach { chapter ->
-                            if (cacheNames.contains(chapter.getFileName()) || chapter.isVolume) {
-                                chapterCaches.add(chapter.url)
-                            }
-                        }
-                    }
-                    cacheChapters[book.bookUrl] = chapterCaches
+                if (!book.isLocal && !cacheCounts.containsKey(book.bookUrl)) {
+                    cacheCounts[book.bookUrl] = calculateCacheCount(book)
                     emitBookChanged(book.bookUrl)
                     emitBookChanged(book.bookUrl)
                 }
                 }
                 ensureActive()
                 ensureActive()
@@ -452,9 +453,28 @@ class BookshelfManageScreenViewModel(
 
 
     private fun onChapterCached(chapter: BookChapter) {
     private fun onChapterCached(chapter: BookChapter) {
         val bookUrl = chapter.bookUrl
         val bookUrl = chapter.bookUrl
-        val chapterSet = cacheChapters.getOrPut(bookUrl) { hashSetOf() }
-        chapterSet.add(chapter.url)
-        emitBookChanged(bookUrl)
+        scheduleCacheCountRefresh(bookUrl)
+    }
+
+    private fun scheduleCacheCountRefresh(bookUrl: String) {
+        cacheCountJobs.remove(bookUrl)?.cancel()
+        cacheCountJobs[bookUrl] = viewModelScope.launch(Dispatchers.IO) {
+            val book = bookDao.getBook(bookUrl) ?: return@launch
+            if (!uiState.value.books.any { it.bookUrl == bookUrl }) {
+                return@launch
+            }
+            cacheCounts[bookUrl] = calculateCacheCount(book)
+            emitBookChanged(bookUrl)
+            cacheCountJobs.remove(bookUrl)
+        }
+    }
+
+    private fun calculateCacheCount(book: Book): Int {
+        val cacheNames = BookHelp.getChapterFiles(book)
+        if (cacheNames.isEmpty()) return 0
+        val totalCount = bookChapterDao.getChapterCount(book.bookUrl)
+        val cachedFileCount = cacheNames.count { it.endsWith(".nb") }
+        return min(cachedFileCount + bookChapterDao.getVolumeCount(book.bookUrl), totalCount)
     }
     }
 
 
     private fun startDownloadForVisibleBooks(books: List<Book>, downloadAllChapters: Boolean) {
     private fun startDownloadForVisibleBooks(books: List<Book>, downloadAllChapters: Boolean) {
@@ -500,7 +520,7 @@ class BookshelfManageScreenViewModel(
             deleteBooksUseCase.execute(bookUrls, deleteOriginal)
             deleteBooksUseCase.execute(bookUrls, deleteOriginal)
         }.onSuccess { deletedBookUrls ->
         }.onSuccess { deletedBookUrls ->
             _uiState.update { it.copy(deleteBookOriginal = deleteOriginal) }
             _uiState.update { it.copy(deleteBookOriginal = deleteOriginal) }
-            deletedBookUrls.forEach { cacheChapters.remove(it) }
+            deletedBookUrls.forEach { cacheCounts.remove(it) }
             _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("删除成功"))
             _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("删除成功"))
         }.onError {
         }.onError {
             _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("删除失败\n${it.localizedMessage}"))
             _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("删除失败\n${it.localizedMessage}"))
@@ -513,7 +533,7 @@ class BookshelfManageScreenViewModel(
             clearBookCacheUseCase.execute(bookUrls)
             clearBookCacheUseCase.execute(bookUrls)
         }.onSuccess { clearedBookUrls ->
         }.onSuccess { clearedBookUrls ->
             clearedBookUrls.forEach { bookUrl ->
             clearedBookUrls.forEach { bookUrl ->
-                cacheChapters[bookUrl] = hashSetOf()
+                cacheCounts[bookUrl] = 0
                 emitBookChanged(bookUrl)
                 emitBookChanged(bookUrl)
             }
             }
             _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("缓存已清理"))
             _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("缓存已清理"))
@@ -578,8 +598,8 @@ class BookshelfManageScreenViewModel(
             changeBookSourceUseCase.changeTo(oldBook, book, chapters, options)
             changeBookSourceUseCase.changeTo(oldBook, book, chapters, options)
         }.onSuccess { result ->
         }.onSuccess { result ->
             result ?: return@onSuccess
             result ?: return@onSuccess
-            cacheChapters.remove(result.oldBookUrl)
-            cacheChapters[result.book.bookUrl] = hashSetOf()
+            cacheCounts.remove(result.oldBookUrl)
+            cacheCounts[result.book.bookUrl] = 0
             emitBookChanged(result.book.bookUrl)
             emitBookChanged(result.book.bookUrl)
             _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("换源完成"))
             _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("换源完成"))
         }.onError {
         }.onError {
@@ -665,15 +685,19 @@ class BookshelfManageScreenViewModel(
         val candidate = item.selectedCandidate ?: return
         val candidate = item.selectedCandidate ?: return
         execute {
         execute {
             val oldBook = bookDao.getBook(oldBookUrl) ?: item.oldBook
             val oldBook = bookDao.getBook(oldBookUrl) ?: item.oldBook
+            val chapters = changeBookSourceUseCase.loadCandidateChapters(
+                candidate.source,
+                candidate.book
+            ) ?: error("获取目录失败")
             changeBookSourceUseCase.changeTo(
             changeBookSourceUseCase.changeTo(
                 oldBook = oldBook,
                 oldBook = oldBook,
                 newBook = candidate.book,
                 newBook = candidate.book,
-                chapters = candidate.chapters,
+                chapters = chapters,
                 options = uiState.value.batchChangeOptions,
                 options = uiState.value.batchChangeOptions,
             )
             )
         }.onSuccess { result ->
         }.onSuccess { result ->
-            cacheChapters.remove(result.oldBookUrl)
-            cacheChapters[result.book.bookUrl] = hashSetOf()
+            cacheCounts.remove(result.oldBookUrl)
+            cacheCounts[result.book.bookUrl] = 0
             removePreviewItem(oldBookUrl)
             removePreviewItem(oldBookUrl)
             emitBookChanged(result.book.bookUrl)
             emitBookChanged(result.book.bookUrl)
             _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("迁移完成"))
             _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("迁移完成"))
@@ -726,8 +750,15 @@ class BookshelfManageScreenViewModel(
             state.copy(
             state.copy(
                 batchChangePreviewItems = state.batchChangePreviewItems.map { item ->
                 batchChangePreviewItems = state.batchChangePreviewItems.map { item ->
                     if (item.oldBook.bookUrl == oldBookUrl) {
                     if (item.oldBook.bookUrl == oldBookUrl) {
+                        book.totalChapterNum = chapters.size
                         item.copy(
                         item.copy(
-                            candidates = listOf(BatchChangeSourceCandidate(source, book, chapters)) +
+                            candidates = listOf(
+                                BatchChangeSourceCandidate(
+                                    source = source,
+                                    book = book,
+                                    chapterCount = chapters.size
+                                )
+                            ) +
                                     item.candidates,
                                     item.candidates,
                             selectedCandidateIndex = 0,
                             selectedCandidateIndex = 0,
                             status = BatchChangeSourcePreviewStatus.Matched
                             status = BatchChangeSourcePreviewStatus.Matched
@@ -746,12 +777,16 @@ class BookshelfManageScreenViewModel(
         } ?: return
         } ?: return
         val candidate = item.selectedCandidate ?: return
         val candidate = item.selectedCandidate ?: return
         execute {
         execute {
+            val chapters = changeBookSourceUseCase.loadCandidateChapters(
+                candidate.source,
+                candidate.book
+            ) ?: error("获取目录失败")
             candidate.book.removeType(BookType.notShelf)
             candidate.book.removeType(BookType.notShelf)
             if (candidate.book.order == 0) {
             if (candidate.book.order == 0) {
                 candidate.book.order = bookDao.minOrder - 1
                 candidate.book.order = bookDao.minOrder - 1
             }
             }
             bookDao.insert(candidate.book)
             bookDao.insert(candidate.book)
-            bookChapterDao.insert(*candidate.chapters.toTypedArray())
+            bookChapterDao.insert(*chapters.toTypedArray())
             candidate.book
             candidate.book
         }.onSuccess {
         }.onSuccess {
             _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("已添加到书架"))
             _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("已添加到书架"))
@@ -795,15 +830,19 @@ class BookshelfManageScreenViewModel(
                 }
                 }
                 val candidate = item.selectedCandidate ?: return@forEachIndexed
                 val candidate = item.selectedCandidate ?: return@forEachIndexed
                 val oldBook = bookDao.getBook(item.oldBook.bookUrl) ?: item.oldBook
                 val oldBook = bookDao.getBook(item.oldBook.bookUrl) ?: item.oldBook
+                val chapters = changeBookSourceUseCase.loadCandidateChapters(
+                    candidate.source,
+                    candidate.book
+                ) ?: return@forEachIndexed
                 changeBookSourceUseCase.changeTo(
                 changeBookSourceUseCase.changeTo(
                     oldBook = oldBook,
                     oldBook = oldBook,
                     newBook = candidate.book,
                     newBook = candidate.book,
-                    chapters = candidate.chapters,
+                    chapters = chapters,
                     options = uiState.value.batchChangeOptions,
                     options = uiState.value.batchChangeOptions,
                 )
                 )
             }
             }
         }.onSuccess {
         }.onSuccess {
-            cacheChapters.clear()
+            cacheCounts.clear()
             _uiState.update { it.copy(batchChangePreviewItems = emptyList()) }
             _uiState.update { it.copy(batchChangePreviewItems = emptyList()) }
             _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("批量迁移完成"))
             _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("批量迁移完成"))
         }.onError {
         }.onError {
@@ -824,7 +863,7 @@ class BookshelfManageScreenViewModel(
             clearBookCacheUseCase.execute(book.bookUrl)
             clearBookCacheUseCase.execute(book.bookUrl)
         }.onSuccess { bookUrl ->
         }.onSuccess { bookUrl ->
             bookUrl ?: return@onSuccess
             bookUrl ?: return@onSuccess
-            cacheChapters[bookUrl] = hashSetOf()
+            cacheCounts[bookUrl] = 0
             emitBookChanged(bookUrl)
             emitBookChanged(bookUrl)
         }
         }
     }
     }

+ 15 - 2
app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt

@@ -50,6 +50,7 @@ class SearchViewModel(
     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 searchSession = repository.createSearchSession { searchScope }
+    private val searchResultBooks = LinkedHashMap<String, SearchBook>()
 
 
     private var currentSearchId = 0L
     private var currentSearchId = 0L
 
 
@@ -231,10 +232,15 @@ class SearchViewModel(
                     }
                     }
 
 
                     is SearchSessionEvent.Progress -> {
                     is SearchSessionEvent.Progress -> {
-                        val shelf = bookshelfKeys.value
+                        event.removedBookUrls.forEach { searchResultBooks.remove(it) }
+                        event.upsertBooks.forEach { book ->
+                            searchResultBooks[book.bookUrl] = book
+                        }
                         _uiState.update {
                         _uiState.update {
                             it.copy(
                             it.copy(
-                                results = event.books.toSearchResultItems(shelf),
+                                results = buildSearchResultItems(
+                                    shelf = bookshelfKeys.value,
+                                ),
                                 processedSources = event.processedSources,
                                 processedSources = event.processedSources,
                                 totalSources = event.totalSources,
                                 totalSources = event.totalSources,
                             )
                             )
@@ -292,6 +298,7 @@ class SearchViewModel(
         updateQuery(keyword, showSuggestions = false)
         updateQuery(keyword, showSuggestions = false)
 
 
         currentSearchId = System.currentTimeMillis()
         currentSearchId = System.currentTimeMillis()
+        searchResultBooks.clear()
         _uiState.update {
         _uiState.update {
             it.copy(
             it.copy(
                 committedQuery = keyword,
                 committedQuery = keyword,
@@ -418,6 +425,12 @@ class SearchViewModel(
         }
         }
     }
     }
 
 
+    private fun buildSearchResultItems(
+        shelf: Set<BookShelfKey>,
+    ): List<SearchResultItemUi> {
+        return searchResultBooks.values.toList().toSearchResultItems(shelf)
+    }
+
     private fun List<SearchResultItemUi>.withShelfState(
     private fun List<SearchResultItemUi>.withShelfState(
         shelf: Set<BookShelfKey>
         shelf: Set<BookShelfKey>
     ): List<SearchResultItemUi> {
     ): List<SearchResultItemUi> {

+ 12 - 5
app/src/main/java/io/legado/app/web/socket/BookSearchWebSocket.kt

@@ -26,6 +26,7 @@ class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) :
 
 
     private val normalClosure = NanoWSD.WebSocketFrame.CloseCode.NormalClosure
     private val normalClosure = NanoWSD.WebSocketFrame.CloseCode.NormalClosure
     private val searchModel = SearchModel(this, this)
     private val searchModel = SearchModel(this, this)
+    private val sentBookUrls = linkedSetOf<String>()
 
 
     private val SEARCH_FINISH = "Search finish"
     private val SEARCH_FINISH = "Search finish"
 
 
@@ -66,6 +67,7 @@ class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) :
                         close(normalClosure, SEARCH_FINISH, false)
                         close(normalClosure, SEARCH_FINISH, false)
                         return@launch
                         return@launch
                     }
                     }
+                    sentBookUrls.clear()
                     searchModel.search(System.currentTimeMillis(), key)
                     searchModel.search(System.currentTimeMillis(), key)
                 }
                 }
             }
             }
@@ -82,19 +84,24 @@ class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) :
 
 
     override fun getSearchScope(): SearchScope = SearchScope(AppConfig.searchScope)
     override fun getSearchScope(): SearchScope = SearchScope(AppConfig.searchScope)
 
 
-    override fun onSearchStart() {
+    override suspend fun onSearchStart() {
 
 
     }
     }
 
 
-    override fun onSearchSuccess(
-        searchBooks: List<SearchBook>,
+    override suspend fun onSearchSuccess(
+        upsertBooks: List<SearchBook>,
+        removedBookUrls: List<String>,
+        resultCount: Int,
         processedSources: Int,
         processedSources: Int,
         totalSources: Int
         totalSources: Int
     ) {
     ) {
-        send(GSON.toJson(searchBooks))
+        val newBooks = upsertBooks.filter { sentBookUrls.add(it.bookUrl) }
+        if (newBooks.isNotEmpty()) {
+            send(GSON.toJson(newBooks))
+        }
     }
     }
 
 
-    override fun onSearchFinish(isEmpty: Boolean, hasMore: Boolean) = close(normalClosure, SEARCH_FINISH, 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)
     override fun onSearchCancel(exception: Throwable?) = close(normalClosure, exception?.toString() ?: SEARCH_FINISH, false)