[优化] 依旧优化某些界面的OOM问题
This commit is contained in:
@@ -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?
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,9 @@ interface SearchBookDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
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")
|
||||
fun clear(name: String, author: String)
|
||||
|
||||
@@ -70,4 +73,4 @@ interface SearchBookDao {
|
||||
|
||||
@Delete
|
||||
fun delete(vararg searchBook: SearchBook)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,9 @@ sealed interface SearchSessionEvent {
|
||||
data object Started : SearchSessionEvent
|
||||
|
||||
data class Progress(
|
||||
val books: List<SearchBook>,
|
||||
val upsertBooks: List<SearchBook>,
|
||||
val removedBookUrls: List<String>,
|
||||
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<SearchSessionEvent>(extraBufferCapacity = 64)
|
||||
private val _events = MutableSharedFlow<SearchSessionEvent>(extraBufferCapacity = 8)
|
||||
override val events: Flow<SearchSessionEvent> = _events.asSharedFlow()
|
||||
|
||||
private val searchModel = SearchModel(sessionScope, object : SearchModel.CallBack {
|
||||
override fun getSearchScope(): SearchScope = scopeProvider()
|
||||
|
||||
override 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,
|
||||
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?) {
|
||||
|
||||
@@ -44,7 +44,7 @@ data class BatchChangeBookSourceResult(
|
||||
data class BatchChangeSourceCandidate(
|
||||
val source: BookSource,
|
||||
val book: Book,
|
||||
val chapters: List<BookChapter>,
|
||||
val chapterCount: Int,
|
||||
)
|
||||
|
||||
data class BatchChangeSourcePreviewItem(
|
||||
@@ -172,14 +172,8 @@ class ChangeBookSourceUseCase(
|
||||
} else {
|
||||
val candidates = arrayListOf<BatchChangeSourceCandidate>()
|
||||
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<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(
|
||||
oldBook: Book,
|
||||
source: BookSource,
|
||||
): Pair<Book, List<BookChapter>>? {
|
||||
): 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<BookChapter>? {
|
||||
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(
|
||||
|
||||
@@ -100,6 +100,9 @@ object CacheBook {
|
||||
MutableStateFlow<Pair<String, Set<Int>>>("" to emptySet())
|
||||
val downloadingIndicesFlow = _downloadingIndicesFlow.asStateFlow()
|
||||
|
||||
private val _queueChangedFlow = MutableSharedFlow<String>(extraBufferCapacity = 64)
|
||||
val queueChangedFlow = _queueChangedFlow.asSharedFlow()
|
||||
|
||||
private val _downloadErrorFlow =
|
||||
MutableStateFlow<Pair<String, Set<Int>>>("" to emptySet())
|
||||
val downloadErrorFlow = _downloadErrorFlow.asStateFlow()
|
||||
@@ -113,6 +116,10 @@ object CacheBook {
|
||||
val cacheBookMap: ConcurrentHashMap<String, CacheBookModel>
|
||||
get() = coordinator.taskMap
|
||||
|
||||
fun errorIndices(bookUrl: String): Set<Int> {
|
||||
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<Int>) {
|
||||
isStopped = false
|
||||
for (i in start..end) {
|
||||
for (i in indices) {
|
||||
if (!onDownloadSet.contains(i)) {
|
||||
waitDownloadSet.add(i)
|
||||
}
|
||||
|
||||
@@ -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<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 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<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)
|
||||
}
|
||||
}
|
||||
}
|
||||
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<SearchBook>, precision: Boolean): SearchBookChange {
|
||||
if (newDataS.isEmpty()) {
|
||||
return SearchBookChange()
|
||||
}
|
||||
|
||||
val upsertBooks = arrayListOf<SearchBook>()
|
||||
val removedBookUrls = linkedSetOf<String>()
|
||||
newDataS.forEach { nBook ->
|
||||
coroutineContext.ensureActive()
|
||||
val bucket = classifyBucket(nBook, precision) ?: return@forEach
|
||||
val key = SearchBookKey(nBook.name, nBook.author)
|
||||
val currentBook = bucket[key]
|
||||
if (currentBook == null) {
|
||||
bucket[key] = nBook
|
||||
upsertBooks.add(nBook)
|
||||
} else {
|
||||
currentBook.addOrigin(nBook.origin)
|
||||
upsertBooks.add(currentBook)
|
||||
}
|
||||
trimSearchBooks()?.let { removed ->
|
||||
removedBookUrls.add(removed.bookUrl)
|
||||
upsertBooks.removeAll { it.bookUrl == removed.bookUrl }
|
||||
}
|
||||
}
|
||||
return SearchBookChange(upsertBooks, removedBookUrls.toList())
|
||||
}
|
||||
|
||||
private fun classifyBucket(
|
||||
book: SearchBook,
|
||||
precision: Boolean,
|
||||
): LinkedHashMap<SearchBookKey, SearchBook>? {
|
||||
return when {
|
||||
book.name == searchKey || book.author == searchKey -> equalBooks
|
||||
book.name.contains(searchKey) || book.author.contains(searchKey) -> containsBooks
|
||||
!precision -> otherBooks
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun trimSearchBooks(): SearchBook? {
|
||||
if (searchBookCount() <= MAX_RETAINED_SEARCH_RESULTS) {
|
||||
return null
|
||||
}
|
||||
resultLimitReached = true
|
||||
return removeLast(otherBooks)
|
||||
?: removeLowestOrigin(containsBooks)
|
||||
?: removeLowestOrigin(equalBooks)
|
||||
}
|
||||
|
||||
private fun removeLast(bucket: LinkedHashMap<SearchBookKey, SearchBook>): SearchBook? {
|
||||
val key = bucket.keys.lastOrNull() ?: return null
|
||||
return bucket.remove(key)
|
||||
}
|
||||
|
||||
private fun removeLowestOrigin(bucket: LinkedHashMap<SearchBookKey, SearchBook>): SearchBook? {
|
||||
val key = bucket.entries.minByOrNull { it.value.origins.size }?.key ?: return null
|
||||
return bucket.remove(key)
|
||||
}
|
||||
|
||||
private fun searchBookCount(): Int {
|
||||
return equalBooks.size + containsBooks.size + otherBooks.size
|
||||
}
|
||||
|
||||
private fun clearSearchBooks() {
|
||||
equalBooks.clear()
|
||||
containsBooks.clear()
|
||||
otherBooks.clear()
|
||||
resultLimitReached = false
|
||||
}
|
||||
|
||||
fun cancelSearch() {
|
||||
@@ -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<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)
|
||||
}
|
||||
|
||||
private data class SearchBookKey(
|
||||
val name: String,
|
||||
val author: String,
|
||||
)
|
||||
|
||||
private data class SearchBookChange(
|
||||
val upsertBooks: List<SearchBook> = emptyList(),
|
||||
val removedBookUrls: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
@@ -163,10 +163,8 @@ class CacheBookService : BaseService() {
|
||||
}
|
||||
}
|
||||
|
||||
//添加每一章到下载队列
|
||||
indices.forEach { index ->
|
||||
cacheBook.addDownload(index)
|
||||
}
|
||||
//添加章节到下载队列
|
||||
cacheBook.addDownloads(indices)
|
||||
|
||||
notificationContent = CacheBook.downloadSummary
|
||||
upCacheBookNotification()
|
||||
|
||||
+9
-12
@@ -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<BookCacheBookItem?>(null) }
|
||||
var pendingDeleteChapter by remember { mutableStateOf<Pair<BookCacheBookItem, BookCacheChapterItem>?>(null) }
|
||||
var expandedBookUrls by rememberSaveable { mutableStateOf(emptySet<String>()) }
|
||||
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<BookCacheBookItem>,
|
||||
expandedBookUrls: Set<String>,
|
||||
chaptersByBookUrl: Map<String, List<BookCacheChapterItem>>,
|
||||
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<String>.toggle(value: String): Set<String> {
|
||||
return if (contains(value)) this - value else this + value
|
||||
}
|
||||
|
||||
private fun chapterStatusText(item: BookCacheChapterItem): String {
|
||||
return when {
|
||||
item.isDownloading -> "下载中"
|
||||
|
||||
+243
-61
@@ -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<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 version: Long = 0,
|
||||
)
|
||||
|
||||
data class BookCacheBookItem(
|
||||
val book: Book,
|
||||
val chapters: List<BookCacheChapterItem>,
|
||||
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<String, List<BookChapter>>()
|
||||
private var fullReloadJob: Job? = null
|
||||
private val bookReloadJobs = hashMapOf<String, Job>()
|
||||
|
||||
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<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 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<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(
|
||||
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<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() {
|
||||
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<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) {
|
||||
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<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>?,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<BookshelfManageScreenEffect>(extraBufferCapacity = 32)
|
||||
val effects = _effects.asSharedFlow()
|
||||
|
||||
private val cacheChapters = hashMapOf<String, HashSet<String>>()
|
||||
private val cacheCounts = ConcurrentHashMap<String, Int>()
|
||||
private var booksJob: Job? = null
|
||||
private var groupsJob: Job? = null
|
||||
private var cacheLoadJob: Job? = null
|
||||
private val cacheCountJobs = ConcurrentHashMap<String, Job>()
|
||||
private var observersStarted = false
|
||||
|
||||
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 {
|
||||
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<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 ->
|
||||
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)
|
||||
}
|
||||
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<Book>, 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ class SearchViewModel(
|
||||
private val bookshelfKeys = MutableStateFlow<Set<BookShelfKey>>(emptySet())
|
||||
private val searchScope = SearchScope(AppConfig.searchScope)
|
||||
private val searchSession = repository.createSearchSession { searchScope }
|
||||
private val searchResultBooks = LinkedHashMap<String, SearchBook>()
|
||||
|
||||
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<BookShelfKey>,
|
||||
): List<SearchResultItemUi> {
|
||||
return searchResultBooks.values.toList().toSearchResultItems(shelf)
|
||||
}
|
||||
|
||||
private fun List<SearchResultItemUi>.withShelfState(
|
||||
shelf: Set<BookShelfKey>
|
||||
): List<SearchResultItemUi> {
|
||||
|
||||
@@ -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<String>()
|
||||
|
||||
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<SearchBook>,
|
||||
override suspend fun onSearchSuccess(
|
||||
upsertBooks: List<SearchBook>,
|
||||
removedBookUrls: List<String>,
|
||||
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user