From c2e1cc9971e7cc19062d0b7143cde9a64730a0ad Mon Sep 17 00:00:00 2001 From: HapeLee <63206378+HapeLee@users.noreply.github.com> Date: Tue, 28 Apr 2026 00:47:53 +0800 Subject: [PATCH] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20=E4=BE=9D=E6=97=A7?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=9F=90=E4=BA=9B=E7=95=8C=E9=9D=A2=E7=9A=84?= =?UTF-8?q?OOM=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../io/legado/app/data/dao/BookChapterDao.kt | 5 +- .../io/legado/app/data/dao/SearchBookDao.kt | 5 +- .../app/data/repository/SearchRepository.kt | 26 +- .../domain/usecase/ChangeBookSourceUseCase.kt | 58 ++-- .../java/io/legado/app/model/CacheBook.kt | 15 +- .../legado/app/model/webBook/SearchModel.kt | 185 ++++++----- .../io/legado/app/service/CacheBookService.kt | 6 +- .../cache/manage/BookCacheManageScreen.kt | 21 +- .../cache/manage/BookCacheManageViewModel.kt | 304 ++++++++++++++---- .../ui/book/manage/BookshelfManageScreen.kt | 6 +- .../manage/BookshelfManageScreenViewModel.kt | 109 +++++-- .../app/ui/book/search/SearchViewModel.kt | 17 +- .../app/web/socket/BookSearchWebSocket.kt | 17 +- 13 files changed, 529 insertions(+), 245 deletions(-) diff --git a/app/src/main/java/io/legado/app/data/dao/BookChapterDao.kt b/app/src/main/java/io/legado/app/data/dao/BookChapterDao.kt index ddfe64454..03c906266 100644 --- a/app/src/main/java/io/legado/app/data/dao/BookChapterDao.kt +++ b/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") 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) fun insert(vararg bookChapter: BookChapter) @@ -59,4 +62,4 @@ interface BookChapterDao { AND `index` = :chapterIndex """) suspend fun getChapterTitleByUrlAndIndex(bookUrl: String, chapterIndex: Int): String? -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/data/dao/SearchBookDao.kt b/app/src/main/java/io/legado/app/data/dao/SearchBookDao.kt index 9444a0964..55e820c4a 100644 --- a/app/src/main/java/io/legado/app/data/dao/SearchBookDao.kt +++ b/app/src/main/java/io/legado/app/data/dao/SearchBookDao.kt @@ -59,6 +59,9 @@ interface SearchBookDao { @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(vararg searchBook: SearchBook): List + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(searchBooks: List): List + @Query("delete from searchBooks where name = :name and author = :author") fun clear(name: String, author: String) @@ -70,4 +73,4 @@ interface SearchBookDao { @Delete fun delete(vararg searchBook: SearchBook) -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/data/repository/SearchRepository.kt b/app/src/main/java/io/legado/app/data/repository/SearchRepository.kt index a8ce16ec9..1103c8ea0 100644 --- a/app/src/main/java/io/legado/app/data/repository/SearchRepository.kt +++ b/app/src/main/java/io/legado/app/data/repository/SearchRepository.kt @@ -39,7 +39,9 @@ sealed interface SearchSessionEvent { data object Started : SearchSessionEvent data class Progress( - val books: List, + val upsertBooks: List, + val removedBookUrls: List, + val resultCount: Int, val processedSources: Int, val totalSources: Int, ) : SearchSessionEvent @@ -120,32 +122,36 @@ class SearchRepositoryImpl( ) : SearchSession { private val sessionScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private val _events = MutableSharedFlow(extraBufferCapacity = 64) + private val _events = MutableSharedFlow(extraBufferCapacity = 8) override val events: Flow = _events.asSharedFlow() private val searchModel = SearchModel(sessionScope, object : SearchModel.CallBack { override fun getSearchScope(): SearchScope = scopeProvider() - override fun onSearchStart() { - _events.tryEmit(SearchSessionEvent.Started) + override suspend fun onSearchStart() { + _events.emit(SearchSessionEvent.Started) } - override fun onSearchSuccess( - searchBooks: List, + override suspend fun onSearchSuccess( + upsertBooks: List, + removedBookUrls: List, + resultCount: Int, processedSources: Int, totalSources: Int, ) { - _events.tryEmit( + _events.emit( SearchSessionEvent.Progress( - books = searchBooks.toList(), + upsertBooks = upsertBooks, + removedBookUrls = removedBookUrls, + resultCount = resultCount, processedSources = processedSources, 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?) { diff --git a/app/src/main/java/io/legado/app/domain/usecase/ChangeBookSourceUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/ChangeBookSourceUseCase.kt index effc25fd1..8a3312157 100644 --- a/app/src/main/java/io/legado/app/domain/usecase/ChangeBookSourceUseCase.kt +++ b/app/src/main/java/io/legado/app/domain/usecase/ChangeBookSourceUseCase.kt @@ -44,7 +44,7 @@ data class BatchChangeBookSourceResult( data class BatchChangeSourceCandidate( val source: BookSource, val book: Book, - val chapters: List, + val chapterCount: Int, ) data class BatchChangeSourcePreviewItem( @@ -172,14 +172,8 @@ class ChangeBookSourceUseCase( } else { val candidates = arrayListOf() 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()) { @@ -199,50 +193,40 @@ class ChangeBookSourceUseCase( .map { it.second } } - fun changePreviewItems( - items: List, - 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( oldBook: Book, source: BookSource, - ): Pair>? { + ): BatchChangeSourceCandidate? { val newBook = WebBook.preciseSearchAwait(source, oldBook.name, oldBook.author) .onFailure { AppLog.put("搜索书籍出错\n${it.localizedMessage}", it, true) }.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? { val infoLoaded = kotlin.runCatching { - if (newBook.tocUrl.isEmpty()) { - WebBook.getBookInfoAwait(source, newBook) + if (book.tocUrl.isEmpty()) { + WebBook.getBookInfoAwait(source, book) } }.onFailure { AppLog.put("获取书籍详情出错\n${it.localizedMessage}", it, true) }.isSuccess if (!infoLoaded) return null - val chapters = WebBook.getChapterListAwait(source, newBook) + val chapters = WebBook.getChapterListAwait(source, book) .onFailure { AppLog.put("获取目录出错\n${it.localizedMessage}", it, true) }.getOrNull() ?: return null - return newBook to chapters + book.totalChapterNum = chapters.size + return chapters } private fun Book.applyMigrationTo( diff --git a/app/src/main/java/io/legado/app/model/CacheBook.kt b/app/src/main/java/io/legado/app/model/CacheBook.kt index 12a093f10..c3c0647dd 100644 --- a/app/src/main/java/io/legado/app/model/CacheBook.kt +++ b/app/src/main/java/io/legado/app/model/CacheBook.kt @@ -100,6 +100,9 @@ object CacheBook { MutableStateFlow>>("" to emptySet()) val downloadingIndicesFlow = _downloadingIndicesFlow.asStateFlow() + private val _queueChangedFlow = MutableSharedFlow(extraBufferCapacity = 64) + val queueChangedFlow = _queueChangedFlow.asSharedFlow() + private val _downloadErrorFlow = MutableStateFlow>>("" to emptySet()) val downloadErrorFlow = _downloadErrorFlow.asStateFlow() @@ -113,6 +116,10 @@ object CacheBook { val cacheBookMap: ConcurrentHashMap get() = coordinator.taskMap + fun errorIndices(bookUrl: String): Set { + return errorIndexMap[bookUrl]?.toSet().orEmpty() + } + private fun collectQueueStats(): QueueStats { var waiting = 0 var downloading = 0 @@ -222,6 +229,7 @@ object CacheBook { private fun onTaskQueuesChanged(bookUrl: String) { updateSummary() + _queueChangedFlow.tryEmit(bookUrl) postEvent(EventBus.UP_DOWNLOAD, bookUrl) } @@ -297,8 +305,13 @@ object CacheBook { @Synchronized fun addDownload(start: Int, end: Int) { + addDownloads(start..end) + } + + @Synchronized + fun addDownloads(indices: Iterable) { isStopped = false - for (i in start..end) { + for (i in indices) { if (!onDownloadSet.contains(i)) { waitDownloadSet.add(i) } diff --git a/app/src/main/java/io/legado/app/model/webBook/SearchModel.kt b/app/src/main/java/io/legado/app/model/webBook/SearchModel.kt index eb80de3d0..babcbaa2f 100644 --- a/app/src/main/java/io/legado/app/model/webBook/SearchModel.kt +++ b/app/src/main/java/io/legado/app/model/webBook/SearchModel.kt @@ -33,15 +33,22 @@ import kotlin.coroutines.coroutineContext import kotlin.math.min class SearchModel(private val scope: CoroutineScope, private val callBack: CallBack) { + private companion object { + const val MAX_RETAINED_SEARCH_RESULTS = 1000 + } + val threadCount = OtherConfig.threadCount private var searchPool: ExecutorCoroutineDispatcher? = null private var mSearchId = 0L private var searchPage = 1 private var searchKey: String = "" private var bookSourceParts = emptyList() - private var searchBooks = arrayListOf() + private val equalBooks = LinkedHashMap() + private val containsBooks = LinkedHashMap() + private val otherBooks = LinkedHashMap() private var searchJob: Job? = null private var workingState = MutableStateFlow(true) + private var resultLimitReached = false private fun initSearchPool() { searchPool?.close() @@ -58,7 +65,7 @@ class SearchModel(private val scope: CoroutineScope, private val callBack: CallB if (mSearchId != 0L) { close() } - searchBooks.clear() + clearSearchBooks() bookSourceParts = callBack.getSearchScope().getBookSourceParts() if (bookSourceParts.isEmpty()) { callBack.onSearchCancel(NoStackTraceException("启用书源为空")) @@ -102,84 +109,100 @@ class SearchModel(private val scope: CoroutineScope, private val callBack: CallB book.releaseHtmlData() } 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() processedParts++ - callBack.onSearchSuccess(searchBooks, processedParts, totalParts) + callBack.onSearchSuccess( + upsertBooks = change.upsertBooks, + removedBookUrls = change.removedBookUrls, + resultCount = searchBookCount(), + processedSources = processedParts, + totalSources = totalParts, + ) }.onCompletion { - if (it == null) callBack.onSearchFinish(searchBooks.isEmpty(), hasMore) + if (it == null) { + callBack.onSearchFinish( + isEmpty = searchBookCount() == 0, + hasMore = hasMore && !resultLimitReached, + ) + } }.catch { AppLog.put("书源搜索出错\n${it.localizedMessage}", it) }.collect() } } - private suspend fun mergeItems(newDataS: List, precision: Boolean) { - if (newDataS.isNotEmpty()) { - val copyData = ArrayList(searchBooks) - val equalData = arrayListOf() - val containsData = arrayListOf() - val otherData = arrayListOf() - 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) - } - } - } - coroutineContext.ensureActive() - equalData.sortByDescending { it.origins.size } - equalData.addAll(containsData.sortedByDescending { it.origins.size }) - if (!precision) { - equalData.addAll(otherData) - } - coroutineContext.ensureActive() - searchBooks = equalData + private suspend fun mergeItems(newDataS: List, precision: Boolean): SearchBookChange { + if (newDataS.isEmpty()) { + return SearchBookChange() } + + val upsertBooks = arrayListOf() + val removedBookUrls = linkedSetOf() + newDataS.forEach { nBook -> + coroutineContext.ensureActive() + val bucket = classifyBucket(nBook, precision) ?: return@forEach + val key = SearchBookKey(nBook.name, nBook.author) + val currentBook = bucket[key] + if (currentBook == null) { + bucket[key] = nBook + upsertBooks.add(nBook) + } else { + currentBook.addOrigin(nBook.origin) + upsertBooks.add(currentBook) + } + trimSearchBooks()?.let { removed -> + removedBookUrls.add(removed.bookUrl) + upsertBooks.removeAll { it.bookUrl == removed.bookUrl } + } + } + return SearchBookChange(upsertBooks, removedBookUrls.toList()) + } + + private fun classifyBucket( + book: SearchBook, + precision: Boolean, + ): LinkedHashMap? { + return when { + book.name == searchKey || book.author == searchKey -> equalBooks + book.name.contains(searchKey) || book.author.contains(searchKey) -> containsBooks + !precision -> otherBooks + else -> null + } + } + + private fun trimSearchBooks(): SearchBook? { + if (searchBookCount() <= MAX_RETAINED_SEARCH_RESULTS) { + return null + } + resultLimitReached = true + return removeLast(otherBooks) + ?: removeLowestOrigin(containsBooks) + ?: removeLowestOrigin(equalBooks) + } + + private fun removeLast(bucket: LinkedHashMap): SearchBook? { + val key = bucket.keys.lastOrNull() ?: return null + return bucket.remove(key) + } + + private fun removeLowestOrigin(bucket: LinkedHashMap): SearchBook? { + val key = bucket.entries.minByOrNull { it.value.origins.size }?.key ?: return null + return bucket.remove(key) + } + + private fun searchBookCount(): Int { + return equalBooks.size + containsBooks.size + otherBooks.size + } + + private fun clearSearchBooks() { + equalBooks.clear() + containsBooks.clear() + otherBooks.clear() + resultLimitReached = false } fun cancelSearch() { @@ -204,10 +227,26 @@ class SearchModel(private val scope: CoroutineScope, private val callBack: CallB interface CallBack { fun getSearchScope(): SearchScope - fun onSearchStart() - fun onSearchSuccess(searchBooks: List, processedSources: Int, totalSources: Int) - fun onSearchFinish(isEmpty: Boolean, hasMore: Boolean) + suspend fun onSearchStart() + suspend fun onSearchSuccess( + upsertBooks: List, + removedBookUrls: List, + resultCount: Int, + processedSources: Int, + totalSources: Int, + ) + suspend fun onSearchFinish(isEmpty: Boolean, hasMore: Boolean) fun onSearchCancel(exception: Throwable? = null) } + private data class SearchBookKey( + val name: String, + val author: String, + ) + + private data class SearchBookChange( + val upsertBooks: List = emptyList(), + val removedBookUrls: List = emptyList(), + ) + } diff --git a/app/src/main/java/io/legado/app/service/CacheBookService.kt b/app/src/main/java/io/legado/app/service/CacheBookService.kt index afe057b48..6f125c57c 100644 --- a/app/src/main/java/io/legado/app/service/CacheBookService.kt +++ b/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 upCacheBookNotification() diff --git a/app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageScreen.kt b/app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageScreen.kt index 0fa693f90..3744f3af4 100644 --- a/app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageScreen.kt +++ b/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.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -94,10 +93,9 @@ private fun BookCacheManageScreen( val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior() var pendingDeleteBook by remember { mutableStateOf(null) } var pendingDeleteChapter by remember { mutableStateOf?>(null) } - var expandedBookUrls by rememberSaveable { mutableStateOf(emptySet()) } val allBooks = state.shelfBooks + state.notShelfBooks 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( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), @@ -160,9 +158,10 @@ private fun BookCacheManageScreen( title = "书架书籍", emptyText = "没有书架内书籍缓存或下载任务", books = state.shelfBooks, - expandedBookUrls = expandedBookUrls, + expandedBookUrls = state.expandedBookUrls, + chaptersByBookUrl = state.chaptersByBookUrl, onToggleExpanded = { bookUrl -> - expandedBookUrls = expandedBookUrls.toggle(bookUrl) + onIntent(BookCacheManageIntent.ToggleBookExpanded(bookUrl)) }, onIntent = onIntent, onDeleteBook = { pendingDeleteBook = it }, @@ -172,9 +171,10 @@ private fun BookCacheManageScreen( title = "未在书架", emptyText = "没有未在书架的书籍下载状态", books = state.notShelfBooks, - expandedBookUrls = expandedBookUrls, + expandedBookUrls = state.expandedBookUrls, + chaptersByBookUrl = state.chaptersByBookUrl, onToggleExpanded = { bookUrl -> - expandedBookUrls = expandedBookUrls.toggle(bookUrl) + onIntent(BookCacheManageIntent.ToggleBookExpanded(bookUrl)) }, onIntent = onIntent, onDeleteBook = { pendingDeleteBook = it }, @@ -212,6 +212,7 @@ private fun LazyListScope.cacheSection( emptyText: String, books: List, expandedBookUrls: Set, + chaptersByBookUrl: Map>, onToggleExpanded: (String) -> Unit, onIntent: (BookCacheManageIntent) -> Unit, onDeleteBook: (BookCacheBookItem) -> Unit, @@ -245,7 +246,7 @@ private fun LazyListScope.cacheSection( } if (expanded) { items( - items = item.chapters, + items = chaptersByBookUrl[bookUrl].orEmpty(), key = { chapter -> "$title-chapter-$bookUrl-${chapter.chapter.url}" } ) { chapter -> BookCacheChapterRow( @@ -392,10 +393,6 @@ private fun BookCacheChapterRow( } } -private fun Set.toggle(value: String): Set { - return if (contains(value)) this - value else this + value -} - private fun chapterStatusText(item: BookCacheChapterItem): String { return when { item.isDownloading -> "下载中" diff --git a/app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageViewModel.kt b/app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageViewModel.kt index 1bff8fac0..ad894d66a 100644 --- a/app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageViewModel.kt +++ b/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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow @@ -23,25 +25,27 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlin.math.min data class BookCacheManageUiState( val isLoading: Boolean = true, val shelfBooks: List = emptyList(), val notShelfBooks: List = emptyList(), + val expandedBookUrls: Set = emptySet(), + val chaptersByBookUrl: Map> = emptyMap(), val downloadSummary: String = CacheBook.downloadSummary, val version: Long = 0, ) data class BookCacheBookItem( val book: Book, - val chapters: List, + val totalCount: Int, val cachedCount: Int, val waitingCount: Int, val downloadingCount: Int, val errorCount: Int, val isNotShelf: Boolean, ) { - val totalCount: Int get() = chapters.size val progress: Float get() = if (totalCount == 0) 0f else cachedCount.toFloat() / totalCount val isDownloading: Boolean get() = waitingCount > 0 || downloadingCount > 0 } @@ -59,6 +63,7 @@ sealed interface BookCacheManageIntent { data object Refresh : BookCacheManageIntent data object StartAllDownloads : BookCacheManageIntent data object StopAllDownloads : BookCacheManageIntent + data class ToggleBookExpanded(val bookUrl: String) : BookCacheManageIntent data class DeleteBookCache(val bookUrl: String) : BookCacheManageIntent data class DownloadChapter(val bookUrl: String, val chapterIndex: Int) : BookCacheManageIntent data class DeleteChapterCache(val bookUrl: String, val chapterUrl: String) : BookCacheManageIntent @@ -83,14 +88,16 @@ class BookCacheManageViewModel( val effects = _effects.asSharedFlow() private var observeJob: Job? = null - private val chapterCache = hashMapOf>() + private var fullReloadJob: Job? = null + private val bookReloadJobs = hashMapOf() fun onIntent(intent: BookCacheManageIntent) { when (intent) { BookCacheManageIntent.Initialize -> initialize() - BookCacheManageIntent.Refresh -> reload() + BookCacheManageIntent.Refresh -> reloadAll(forceDatabase = true) BookCacheManageIntent.StartAllDownloads -> startAllDownloads() BookCacheManageIntent.StopAllDownloads -> stopAllDownloads() + is BookCacheManageIntent.ToggleBookExpanded -> toggleBookExpanded(intent.bookUrl) is BookCacheManageIntent.DeleteBookCache -> deleteBookCache(intent.bookUrl) is BookCacheManageIntent.DownloadChapter -> downloadChapter( intent.bookUrl, @@ -106,23 +113,28 @@ class BookCacheManageViewModel( private fun initialize() { if (observeJob != null) return observeJob = viewModelScope.launch { - bookDao.flowAll().collect { - reload(it) + bookDao.flowAll().collect { books -> + reloadAll(books = books, forceDatabase = false) } } viewModelScope.launch { - CacheBook.cacheSuccessFlow.collect { - reload() + CacheBook.cacheSuccessFlow.collect { chapter -> + scheduleBookReload(chapter.bookUrl) } } viewModelScope.launch { - CacheBook.downloadingIndicesFlow.collect { - reload() + CacheBook.downloadingIndicesFlow.collect { state -> + scheduleBookReload(state.first) } } viewModelScope.launch { - CacheBook.downloadErrorFlow.collect { - reload() + CacheBook.queueChangedFlow.collect { bookUrl -> + scheduleBookReload(bookUrl) + } + } + viewModelScope.launch { + CacheBook.downloadErrorFlow.collect { state -> + scheduleBookReload(state.first) } } viewModelScope.launch { @@ -130,53 +142,146 @@ class BookCacheManageViewModel( _uiState.update { it.copy(downloadSummary = summary, version = it.version + 1) } - reload() } } } - private fun reload(books: List? = 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? = 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 { 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 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 chapters = chapterCache.getOrPut(book.bookUrl) { - 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 = 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 } - 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 { + 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( chapter = chapter, isCached = cacheFiles.contains(chapter.getFileName()) || chapter.isVolume, @@ -185,29 +290,68 @@ class BookCacheManageViewModel( 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 { + 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() { CacheBook.stop(context) - reload() + reloadAll(forceDatabase = true) } private fun startAllDownloads() { val items = uiState.value.shelfBooks + uiState.value.notShelfBooks execute { 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) } }.onSuccess { count -> @@ -219,10 +363,27 @@ class BookCacheManageViewModel( }.onError { _effects.tryEmit(BookCacheManageEffect.ShowMessage("加入缓存队列失败\n${it.localizedMessage}")) }.onFinally { - reload() + reloadAll(forceDatabase = true) } } + private fun downloadableChapterIndices(book: Book): List { + 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) { CacheBook.remove(context, bookUrl) execute { @@ -232,7 +393,7 @@ class BookCacheManageViewModel( }.onError { _effects.tryEmit(BookCacheManageEffect.ShowMessage("删除缓存失败\n${it.localizedMessage}")) }.onFinally { - reload() + scheduleBookReload(bookUrl, debounceMillis = 0) } } @@ -244,13 +405,17 @@ class BookCacheManageViewModel( }.onError { _effects.tryEmit(BookCacheManageEffect.ShowMessage("章节缓存失败\n${it.localizedMessage}")) }.onFinally { - reload() + scheduleBookReload(bookUrl, debounceMillis = 0) } } 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 { BookHelp.delContent(book, chapter) }.onSuccess { @@ -258,7 +423,24 @@ class BookCacheManageViewModel( }.onError { _effects.tryEmit(BookCacheManageEffect.ShowMessage("删除章节缓存失败\n${it.localizedMessage}")) }.onFinally { - reload() + scheduleBookReload(bookUrl, debounceMillis = 0) } } + + private fun sortItems(items: List): List { + return items.sortedWith(compareByDescending { it.isDownloading } + .thenByDescending { it.cachedCount } + .thenBy { it.book.name }) + } + + private data class LoadedCacheState( + val items: List, + val expandedBookUrls: Set, + val chaptersByBookUrl: Map>, + ) + + private data class LoadedBookState( + val item: BookCacheBookItem?, + val chapters: List?, + ) } diff --git a/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt b/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt index d11ac8994..bb352914d 100644 --- a/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt +++ b/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 -> val cacheCount = remember(renderVersion, book.bookUrl) { - viewModel.getCacheChapters(book.bookUrl)?.size ?: 0 + viewModel.getCacheCount(book.bookUrl) ?: 0 } val isDownloading = remember(renderVersion, book.bookUrl) { viewModel.isBookDownloading(book.bookUrl) @@ -1512,7 +1512,7 @@ private fun BatchChangePreviewRow( chapterCount = if (item.status == BatchChangeSourcePreviewStatus.Skipped) { null } else { - candidate?.chapters?.size + candidate?.chapterCount }, onClick = { candidate?.book?.let { onOpenBook(it, false) } @@ -1624,7 +1624,7 @@ private fun OtherSourceOptionsSheet( val candidate = currentItem.candidates[index] SelectionItemCard( title = candidate.source.bookSourceName, - subtitle = "${candidate.book.name} · ${candidate.chapters.size}章", + subtitle = "${candidate.book.name} · ${candidate.chapterCount}章", supportingContent = { AppText( text = candidate.book.getRealAuthor(), diff --git a/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreenViewModel.kt b/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreenViewModel.kt index 995d6d328..386ee4630 100644 --- a/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreenViewModel.kt +++ b/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.utils.cnCompare import io.legado.app.utils.move +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableSharedFlow @@ -45,6 +46,8 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlin.math.max +import kotlin.math.min +import java.util.concurrent.ConcurrentHashMap data class BookshelfManageScreenExportConfig( val exportUseReplace: Boolean = true, @@ -158,9 +161,11 @@ class BookshelfManageScreenViewModel( private val _effects = MutableSharedFlow(extraBufferCapacity = 32) val effects = _effects.asSharedFlow() - private val cacheChapters = hashMapOf>() + private val cacheCounts = ConcurrentHashMap() private var booksJob: Job? = null private var groupsJob: Job? = null + private var cacheLoadJob: Job? = null + private val cacheCountJobs = ConcurrentHashMap() private var observersStarted = false fun dispatch(intent: BookshelfManageScreenIntent) { @@ -285,9 +290,7 @@ class BookshelfManageScreenViewModel( } } - fun getCacheChapters(bookUrl: String): Set? = cacheChapters[bookUrl] - - fun getAllCacheChapters(): Map> = cacheChapters + fun getCacheCount(bookUrl: String): Int? = cacheCounts[bookUrl] fun isBookDownloading(bookUrl: String): Boolean { return CacheBook.cacheBookMap[bookUrl]?.isStop() == false @@ -354,7 +357,7 @@ class BookshelfManageScreenViewModel( bookSortOrder = bookshelfManageScreenConfig.bookshelfSortOrder ) } - loadCacheFiles(books) + loadCacheCounts(books) } } } @@ -427,22 +430,20 @@ class BookshelfManageScreenViewModel( } } - private fun loadCacheFiles(books: List) { - execute { + private fun loadCacheCounts(books: List) { + 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 -> - if (!book.isLocal && !cacheChapters.contains(book.bookUrl)) { - val chapterCaches = hashSetOf() - 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) } ensureActive() @@ -452,9 +453,28 @@ class BookshelfManageScreenViewModel( private fun onChapterCached(chapter: BookChapter) { 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, downloadAllChapters: Boolean) { @@ -500,7 +520,7 @@ class BookshelfManageScreenViewModel( deleteBooksUseCase.execute(bookUrls, deleteOriginal) }.onSuccess { deletedBookUrls -> _uiState.update { it.copy(deleteBookOriginal = deleteOriginal) } - deletedBookUrls.forEach { cacheChapters.remove(it) } + deletedBookUrls.forEach { cacheCounts.remove(it) } _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("删除成功")) }.onError { _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("删除失败\n${it.localizedMessage}")) @@ -513,7 +533,7 @@ class BookshelfManageScreenViewModel( clearBookCacheUseCase.execute(bookUrls) }.onSuccess { clearedBookUrls -> clearedBookUrls.forEach { bookUrl -> - cacheChapters[bookUrl] = hashSetOf() + cacheCounts[bookUrl] = 0 emitBookChanged(bookUrl) } _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("缓存已清理")) @@ -578,8 +598,8 @@ class BookshelfManageScreenViewModel( changeBookSourceUseCase.changeTo(oldBook, book, chapters, options) }.onSuccess { result -> 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) _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("换源完成")) }.onError { @@ -665,15 +685,19 @@ class BookshelfManageScreenViewModel( val candidate = item.selectedCandidate ?: return execute { val oldBook = bookDao.getBook(oldBookUrl) ?: item.oldBook + val chapters = changeBookSourceUseCase.loadCandidateChapters( + candidate.source, + candidate.book + ) ?: error("获取目录失败") changeBookSourceUseCase.changeTo( oldBook = oldBook, newBook = candidate.book, - chapters = candidate.chapters, + chapters = chapters, options = uiState.value.batchChangeOptions, ) }.onSuccess { result -> - cacheChapters.remove(result.oldBookUrl) - cacheChapters[result.book.bookUrl] = hashSetOf() + cacheCounts.remove(result.oldBookUrl) + cacheCounts[result.book.bookUrl] = 0 removePreviewItem(oldBookUrl) emitBookChanged(result.book.bookUrl) _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("迁移完成")) @@ -726,8 +750,15 @@ class BookshelfManageScreenViewModel( state.copy( batchChangePreviewItems = state.batchChangePreviewItems.map { item -> if (item.oldBook.bookUrl == oldBookUrl) { + book.totalChapterNum = chapters.size item.copy( - candidates = listOf(BatchChangeSourceCandidate(source, book, chapters)) + + candidates = listOf( + BatchChangeSourceCandidate( + source = source, + book = book, + chapterCount = chapters.size + ) + ) + item.candidates, selectedCandidateIndex = 0, status = BatchChangeSourcePreviewStatus.Matched @@ -746,12 +777,16 @@ class BookshelfManageScreenViewModel( } ?: return val candidate = item.selectedCandidate ?: return execute { + val chapters = changeBookSourceUseCase.loadCandidateChapters( + candidate.source, + candidate.book + ) ?: error("获取目录失败") candidate.book.removeType(BookType.notShelf) if (candidate.book.order == 0) { candidate.book.order = bookDao.minOrder - 1 } bookDao.insert(candidate.book) - bookChapterDao.insert(*candidate.chapters.toTypedArray()) + bookChapterDao.insert(*chapters.toTypedArray()) candidate.book }.onSuccess { _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("已添加到书架")) @@ -795,15 +830,19 @@ class BookshelfManageScreenViewModel( } val candidate = item.selectedCandidate ?: return@forEachIndexed val oldBook = bookDao.getBook(item.oldBook.bookUrl) ?: item.oldBook + val chapters = changeBookSourceUseCase.loadCandidateChapters( + candidate.source, + candidate.book + ) ?: return@forEachIndexed changeBookSourceUseCase.changeTo( oldBook = oldBook, newBook = candidate.book, - chapters = candidate.chapters, + chapters = chapters, options = uiState.value.batchChangeOptions, ) } }.onSuccess { - cacheChapters.clear() + cacheCounts.clear() _uiState.update { it.copy(batchChangePreviewItems = emptyList()) } _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("批量迁移完成")) }.onError { @@ -824,7 +863,7 @@ class BookshelfManageScreenViewModel( clearBookCacheUseCase.execute(book.bookUrl) }.onSuccess { bookUrl -> bookUrl ?: return@onSuccess - cacheChapters[bookUrl] = hashSetOf() + cacheCounts[bookUrl] = 0 emitBookChanged(bookUrl) } } diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt index a11a3524c..2f0988d80 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt @@ -50,6 +50,7 @@ class SearchViewModel( private val bookshelfKeys = MutableStateFlow>(emptySet()) private val searchScope = SearchScope(AppConfig.searchScope) private val searchSession = repository.createSearchSession { searchScope } + private val searchResultBooks = LinkedHashMap() private var currentSearchId = 0L @@ -231,10 +232,15 @@ class SearchViewModel( } is SearchSessionEvent.Progress -> { - val shelf = bookshelfKeys.value + event.removedBookUrls.forEach { searchResultBooks.remove(it) } + event.upsertBooks.forEach { book -> + searchResultBooks[book.bookUrl] = book + } _uiState.update { it.copy( - results = event.books.toSearchResultItems(shelf), + results = buildSearchResultItems( + shelf = bookshelfKeys.value, + ), processedSources = event.processedSources, totalSources = event.totalSources, ) @@ -292,6 +298,7 @@ class SearchViewModel( updateQuery(keyword, showSuggestions = false) currentSearchId = System.currentTimeMillis() + searchResultBooks.clear() _uiState.update { it.copy( committedQuery = keyword, @@ -418,6 +425,12 @@ class SearchViewModel( } } + private fun buildSearchResultItems( + shelf: Set, + ): List { + return searchResultBooks.values.toList().toSearchResultItems(shelf) + } + private fun List.withShelfState( shelf: Set ): List { diff --git a/app/src/main/java/io/legado/app/web/socket/BookSearchWebSocket.kt b/app/src/main/java/io/legado/app/web/socket/BookSearchWebSocket.kt index 7187a1f0c..1e0158f1b 100644 --- a/app/src/main/java/io/legado/app/web/socket/BookSearchWebSocket.kt +++ b/app/src/main/java/io/legado/app/web/socket/BookSearchWebSocket.kt @@ -26,6 +26,7 @@ class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) : private val normalClosure = NanoWSD.WebSocketFrame.CloseCode.NormalClosure private val searchModel = SearchModel(this, this) + private val sentBookUrls = linkedSetOf() private val SEARCH_FINISH = "Search finish" @@ -66,6 +67,7 @@ class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) : close(normalClosure, SEARCH_FINISH, false) return@launch } + sentBookUrls.clear() searchModel.search(System.currentTimeMillis(), key) } } @@ -82,19 +84,24 @@ class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) : override fun getSearchScope(): SearchScope = SearchScope(AppConfig.searchScope) - override fun onSearchStart() { + override suspend fun onSearchStart() { } - override fun onSearchSuccess( - searchBooks: List, + override suspend fun onSearchSuccess( + upsertBooks: List, + removedBookUrls: List, + resultCount: Int, processedSources: 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)