[优化] 重写下载核心

This commit is contained in:
HapeLee
2026-04-28 16:14:52 +08:00
parent 29505aa4ef
commit fb31b55125
19 changed files with 941 additions and 181 deletions
@@ -2,15 +2,36 @@ package io.legado.app.data.repository
import io.legado.app.data.dao.BookDao import io.legado.app.data.dao.BookDao
import io.legado.app.domain.gateway.BookCacheDownloadGateway import io.legado.app.domain.gateway.BookCacheDownloadGateway
import io.legado.app.help.book.isLocal
import io.legado.app.model.CacheBook import io.legado.app.model.CacheBook
import io.legado.app.model.cache.CacheDownloadRequest
import io.legado.app.model.cache.ChapterSelection
import splitties.init.appCtx import splitties.init.appCtx
class CacheBookDownloadRepository( class CacheBookDownloadRepository(
private val bookDao: BookDao private val bookDao: BookDao
) : BookCacheDownloadGateway { ) : BookCacheDownloadGateway {
override suspend fun start(request: CacheDownloadRequest) {
val book = bookDao.getBook(request.bookUrl) ?: return
CacheBook.start(appCtx, request, isLocal = book.isLocal)
}
override suspend fun start(bookUrl: String, chapterIndices: List<Int>) { override suspend fun start(bookUrl: String, chapterIndices: List<Int>) {
val book = bookDao.getBook(bookUrl) ?: return start(
CacheBook.start(appCtx, book, chapterIndices) CacheDownloadRequest(
bookUrl = bookUrl,
selection = ChapterSelection.Indices(chapterIndices.toSet()),
)
)
}
override suspend fun start(bookUrl: String, startIndex: Int, endIndex: Int) {
start(
CacheDownloadRequest(
bookUrl = bookUrl,
selection = ChapterSelection.Range(startIndex, endIndex),
)
)
} }
} }
@@ -30,6 +30,7 @@ import io.legado.app.data.repository.WebDavReadingProgressRepository
import io.legado.app.domain.gateway.BookCacheCleanupGateway import io.legado.app.domain.gateway.BookCacheCleanupGateway
import io.legado.app.domain.gateway.AppStartupGateway import io.legado.app.domain.gateway.AppStartupGateway
import io.legado.app.domain.gateway.BookCacheDownloadGateway import io.legado.app.domain.gateway.BookCacheDownloadGateway
import io.legado.app.domain.gateway.BookSearchGateway
import io.legado.app.domain.gateway.BookSourceCallbackGateway import io.legado.app.domain.gateway.BookSourceCallbackGateway
import io.legado.app.domain.gateway.DatabaseMaintenanceGateway import io.legado.app.domain.gateway.DatabaseMaintenanceGateway
import io.legado.app.domain.gateway.LocalBookGateway import io.legado.app.domain.gateway.LocalBookGateway
@@ -46,6 +47,7 @@ import io.legado.app.domain.usecase.GetReadingProgressUseCase
import io.legado.app.domain.usecase.RemoveBookGroupAssignmentUseCase import io.legado.app.domain.usecase.RemoveBookGroupAssignmentUseCase
import io.legado.app.ui.widget.components.explore.ExploreKindUiUseCase import io.legado.app.ui.widget.components.explore.ExploreKindUiUseCase
import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
import io.legado.app.domain.usecase.SearchBooksUseCase
import io.legado.app.domain.usecase.ShrinkDatabaseUseCase import io.legado.app.domain.usecase.ShrinkDatabaseUseCase
import io.legado.app.domain.usecase.UpdateBooksGroupUseCase import io.legado.app.domain.usecase.UpdateBooksGroupUseCase
import io.legado.app.domain.usecase.UploadReadingProgressUseCase import io.legado.app.domain.usecase.UploadReadingProgressUseCase
@@ -140,7 +142,12 @@ val appModule = module {
single<ReadingProgressGateway> { WebDavReadingProgressRepository() } single<ReadingProgressGateway> { WebDavReadingProgressRepository() }
single<BookDomainRepository> { BookDomainRepositoryImpl(get(), get()) } single<BookDomainRepository> { BookDomainRepositoryImpl(get(), get()) }
single<ExploreRepository> { ExploreRepositoryImpl(get()) } single<ExploreRepository> { ExploreRepositoryImpl(get()) }
single<SearchRepository> { SearchRepositoryImpl(get()) } single {
SearchRepositoryImpl(get())
}
single<SearchRepository> { get<SearchRepositoryImpl>() }
single<BookSearchGateway> { get<SearchRepositoryImpl>() }
singleOf(::SearchBooksUseCase)
single<ImageLoader> { single<ImageLoader> {
ImageLoader.Builder(get()) ImageLoader.Builder(get())
@@ -1,5 +1,9 @@
package io.legado.app.domain.gateway package io.legado.app.domain.gateway
import io.legado.app.model.cache.CacheDownloadRequest
interface BookCacheDownloadGateway { interface BookCacheDownloadGateway {
suspend fun start(request: CacheDownloadRequest)
suspend fun start(bookUrl: String, chapterIndices: List<Int>) suspend fun start(bookUrl: String, chapterIndices: List<Int>)
suspend fun start(bookUrl: String, startIndex: Int, endIndex: Int)
} }
@@ -3,6 +3,9 @@ package io.legado.app.domain.usecase
import io.legado.app.domain.gateway.BookCacheDownloadGateway import io.legado.app.domain.gateway.BookCacheDownloadGateway
import io.legado.app.domain.model.CacheableBook import io.legado.app.domain.model.CacheableBook
import io.legado.app.domain.repository.BookDomainRepository import io.legado.app.domain.repository.BookDomainRepository
import io.legado.app.model.cache.CacheDownloadRequest
import io.legado.app.model.cache.CacheDownloadSource
import io.legado.app.model.cache.ChapterSelection
class BatchCacheDownloadUseCase( class BatchCacheDownloadUseCase(
private val bookRepository: BookDomainRepository, private val bookRepository: BookDomainRepository,
@@ -31,13 +34,16 @@ class BatchCacheDownloadUseCase(
): Boolean { ): Boolean {
if (book.isLocal) return false if (book.isLocal) return false
if (skipAudioBooks && book.isAudio) return false if (skipAudioBooks && book.isAudio) return false
val indices = if (downloadAllChapters) { val startIndex = if (downloadAllChapters) 0 else book.durChapterIndex
(0..book.lastChapterIndex).toList() val endIndex = book.lastChapterIndex
} else { if (endIndex < startIndex) return false
(book.durChapterIndex..book.lastChapterIndex).toList() bookCacheDownloadGateway.start(
} CacheDownloadRequest(
if (indices.isEmpty()) return false bookUrl = book.bookUrl,
bookCacheDownloadGateway.start(book.bookUrl, indices) selection = ChapterSelection.Range(startIndex, endIndex),
source = CacheDownloadSource.Batch,
)
)
return true return true
} }
} }
@@ -1,6 +1,9 @@
package io.legado.app.domain.usecase package io.legado.app.domain.usecase
import io.legado.app.domain.gateway.BookCacheDownloadGateway import io.legado.app.domain.gateway.BookCacheDownloadGateway
import io.legado.app.model.cache.CacheDownloadRequest
import io.legado.app.model.cache.CacheDownloadSource
import io.legado.app.model.cache.ChapterSelection
class CacheBookChaptersUseCase( class CacheBookChaptersUseCase(
private val bookCacheDownloadGateway: BookCacheDownloadGateway private val bookCacheDownloadGateway: BookCacheDownloadGateway
@@ -9,7 +12,25 @@ class CacheBookChaptersUseCase(
suspend fun execute(bookUrl: String, chapterIndices: Iterable<Int>): Int { suspend fun execute(bookUrl: String, chapterIndices: Iterable<Int>): Int {
val indices = chapterIndices.distinct() val indices = chapterIndices.distinct()
if (indices.isEmpty()) return 0 if (indices.isEmpty()) return 0
bookCacheDownloadGateway.start(bookUrl, indices) bookCacheDownloadGateway.start(
CacheDownloadRequest(
bookUrl = bookUrl,
selection = ChapterSelection.Indices(indices.toSet()),
source = CacheDownloadSource.Manual,
)
)
return indices.size return indices.size
} }
suspend fun executeRange(bookUrl: String, startIndex: Int, endIndex: Int): Int {
if (endIndex < startIndex) return 0
bookCacheDownloadGateway.start(
CacheDownloadRequest(
bookUrl = bookUrl,
selection = ChapterSelection.Range(startIndex, endIndex),
source = CacheDownloadSource.Manual,
)
)
return endIndex - startIndex + 1
}
} }
@@ -2,22 +2,26 @@ package io.legado.app.model
import android.content.Context import android.content.Context
import io.legado.app.constant.AppLog import io.legado.app.constant.AppLog
import io.legado.app.constant.EventBus
import io.legado.app.constant.IntentAction import io.legado.app.constant.IntentAction
import io.legado.app.data.appDb import io.legado.app.data.appDb
import io.legado.app.data.entities.Book import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSource
import io.legado.app.exception.ConcurrentException import io.legado.app.exception.ConcurrentException
import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.isLocal import io.legado.app.help.book.isLocal
import io.legado.app.help.coroutine.CompositeCoroutine import io.legado.app.help.coroutine.CompositeCoroutine
import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.coroutine.Coroutine
import io.legado.app.model.webBook.WebBook import io.legado.app.model.cache.CacheDownloadQueue
import io.legado.app.model.cache.CacheDownloadRepository
import io.legado.app.model.cache.CacheDownloadRequest
import io.legado.app.model.cache.CacheDownloadSource
import io.legado.app.model.cache.CacheDownloadStateStore
import io.legado.app.model.cache.ChapterSelection
import io.legado.app.model.cache.ReadingCacheEvent
import io.legado.app.model.cache.ReadingCacheEvents
import io.legado.app.service.CacheBookService import io.legado.app.service.CacheBookService
import io.legado.app.ui.config.otherConfig.OtherConfig import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.utils.onEachParallel import io.legado.app.utils.onEachParallel
import io.legado.app.utils.postEvent
import io.legado.app.utils.startService import io.legado.app.utils.startService
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -75,20 +79,20 @@ object CacheBook {
if (!emitted) delay(800) if (!emitted) delay(800)
} }
}.onStart { }.onStart {
postEvent(EventBus.UP_DOWNLOAD_STATE, "")
updateSummary() updateSummary()
}.onEachParallel(OtherConfig.cacheBookThreadCount.coerceAtLeast(1)) { }.onEachParallel(OtherConfig.cacheBookThreadCount.coerceAtLeast(1)) {
coroutineScope { coroutineScope {
it.download(this, context) it.download(this, context)
} }
}.onCompletion { }.onCompletion {
postEvent(EventBus.UP_DOWNLOAD_STATE, "")
updateSummary() updateSummary()
}.collect() }.collect()
} }
} }
private val coordinator = CacheBookCoordinator() private val coordinator = CacheBookCoordinator()
private val stateStore = CacheDownloadStateStore()
val downloadStateFlow = stateStore.stateFlow
private val _cacheSuccessFlow = MutableSharedFlow<BookChapter>(extraBufferCapacity = 64) private val _cacheSuccessFlow = MutableSharedFlow<BookChapter>(extraBufferCapacity = 64)
val cacheSuccessFlow = _cacheSuccessFlow.asSharedFlow() val cacheSuccessFlow = _cacheSuccessFlow.asSharedFlow()
@@ -117,7 +121,7 @@ object CacheBook {
get() = coordinator.taskMap get() = coordinator.taskMap
fun errorIndices(bookUrl: String): Set<Int> { fun errorIndices(bookUrl: String): Set<Int> {
return errorIndexMap[bookUrl]?.toSet().orEmpty() return stateStore.bookState(bookUrl)?.failedIndices.orEmpty()
} }
private fun collectQueueStats(): QueueStats { private fun collectQueueStats(): QueueStats {
@@ -168,11 +172,54 @@ object CacheBook {
} }
fun start(context: Context, book: Book, selectedIndices: List<Int>) { fun start(context: Context, book: Book, selectedIndices: List<Int>) {
if (!book.isLocal && selectedIndices.isNotEmpty()) { start(
context.startService<CacheBookService> { context = context,
action = IntentAction.start request = CacheDownloadRequest(
putExtra("bookUrl", book.bookUrl) bookUrl = book.bookUrl,
putIntegerArrayListExtra("indices", ArrayList(selectedIndices)) selection = ChapterSelection.Indices(selectedIndices.toSet()),
),
isLocal = book.isLocal,
)
}
fun start(context: Context, book: Book, startIndex: Int, endIndex: Int) {
start(
context = context,
request = CacheDownloadRequest(
bookUrl = book.bookUrl,
selection = ChapterSelection.Range(startIndex, endIndex),
),
isLocal = book.isLocal,
)
}
fun start(context: Context, request: CacheDownloadRequest, isLocal: Boolean = false) {
if (isLocal) return
when (val selection = request.selection) {
is ChapterSelection.Range -> {
if (selection.end < selection.start) return
}
is ChapterSelection.Indices -> {
if (selection.values.isEmpty()) return
}
is ChapterSelection.Single -> Unit
}
context.startService<CacheBookService> {
action = IntentAction.start
putExtra("bookUrl", request.bookUrl)
putExtra("source", request.source.name)
when (val selection = request.selection) {
is ChapterSelection.Range -> {
putExtra("start", selection.start)
putExtra("end", selection.end)
}
is ChapterSelection.Indices -> {
putIntegerArrayListExtra("indices", ArrayList(selection.values))
}
is ChapterSelection.Single -> {
putExtra("start", selection.index)
putExtra("end", selection.index)
}
} }
} }
} }
@@ -187,9 +234,9 @@ object CacheBook {
fun removeBook(bookUrl: String): Boolean { fun removeBook(bookUrl: String): Boolean {
val model = cacheBookMap.remove(bookUrl) ?: return false val model = cacheBookMap.remove(bookUrl) ?: return false
model.stop() model.stop()
stateStore.removeBook(bookUrl)
updateSummary() updateSummary()
_queueChangedFlow.tryEmit(bookUrl) _queueChangedFlow.tryEmit(bookUrl)
postEvent(EventBus.UP_DOWNLOAD, bookUrl)
return true return true
} }
@@ -211,6 +258,7 @@ object CacheBook {
successDownloadSet.clear() successDownloadSet.clear()
errorDownloadMap.clear() errorDownloadMap.clear()
errorIndexMap.clear() errorIndexMap.clear()
stateStore.clear()
updateSummary() updateSummary()
} }
@@ -241,15 +289,26 @@ object CacheBook {
get() = lastQueueStats.waitingCount > 0 || lastQueueStats.downloadingCount > 0 get() = lastQueueStats.waitingCount > 0 || lastQueueStats.downloadingCount > 0
private fun onTaskQueuesChanged(bookUrl: String) { private fun onTaskQueuesChanged(bookUrl: String) {
cacheBookMap[bookUrl]?.let { model ->
stateStore.updateBookQueue(
bookUrl = bookUrl,
waitingCount = model.queueCounts().first,
runningIndices = model.downloadingIndices(),
)
_downloadingIndicesFlow.tryEmit(bookUrl to model.downloadingIndices())
_downloadErrorFlow.tryEmit(bookUrl to errorIndices(bookUrl))
}
updateSummary() updateSummary()
_queueChangedFlow.tryEmit(bookUrl) _queueChangedFlow.tryEmit(bookUrl)
postEvent(EventBus.UP_DOWNLOAD, bookUrl)
} }
private fun onTaskRemoved(bookUrl: String) { private fun onTaskRemoved(bookUrl: String, clearState: Boolean = false) {
cacheBookMap.remove(bookUrl) cacheBookMap.remove(bookUrl)
if (clearState) {
stateStore.removeBook(bookUrl)
}
updateSummary() updateSummary()
postEvent(EventBus.UP_DOWNLOAD, bookUrl) _queueChangedFlow.tryEmit(bookUrl)
} }
class CacheBookModel( class CacheBookModel(
@@ -257,40 +316,50 @@ object CacheBook {
@Volatile var book: Book @Volatile var book: Book
) { ) {
private val waitDownloadSet = linkedSetOf<Int>() private val queue = CacheDownloadQueue()
private val onDownloadSet = linkedSetOf<Int>() private val onDownloadSet = linkedSetOf<Int>()
private val pausedDownloadSet = hashSetOf<Int>() private val pausedDownloadSet = hashSetOf<Int>()
private val chapterTasks = hashMapOf<Int, Coroutine<*>>() private val chapterTasks = hashMapOf<Int, Coroutine<*>>()
private val tasks = CompositeCoroutine() private val tasks = CompositeCoroutine()
private val repository = CacheDownloadRepository()
private var isStopped = false private var isStopped = false
private var waitingRetry = false private var waitingRetry = false
private var isLoading = false private var isLoading = false
init {
postEvent(EventBus.UP_DOWNLOAD, book.bookUrl)
}
private fun notifyDownloadSetChanged() { private fun notifyDownloadSetChanged() {
_downloadingIndicesFlow.tryEmit(book.bookUrl to onDownloadSet.toSet()) _downloadingIndicesFlow.tryEmit(book.bookUrl to onDownloadSet.toSet())
if (cacheBookMap[book.bookUrl] === this) {
stateStore.updateBookQueue(
bookUrl = book.bookUrl,
waitingCount = queue.waitingCount(),
runningIndices = onDownloadSet.toSet(),
)
}
} }
private fun notifyErrorChanged() { private fun notifyErrorChanged() {
val errors = errorIndexMap[book.bookUrl]?.toSet() ?: emptySet() val errors = errorIndices(book.bookUrl)
_downloadErrorFlow.tryEmit(book.bookUrl to errors) _downloadErrorFlow.tryEmit(book.bookUrl to errors)
} }
@Synchronized @Synchronized
fun queueCounts(): Pair<Int, Int> = waitDownloadSet.size to onDownloadSet.size fun queueCounts(): Pair<Int, Int> = queue.waitingCount() to onDownloadSet.size
@Synchronized @Synchronized
fun waitingIndices(): Set<Int> = waitDownloadSet.toSet() fun isWaiting(index: Int): Boolean = queue.isWaiting(index)
@Synchronized
fun isDownloading(index: Int): Boolean = onDownloadSet.contains(index)
@Synchronized
fun waitingIndices(): Set<Int> = queue.waitingIndices()
@Synchronized @Synchronized
fun downloadingIndices(): Set<Int> = onDownloadSet.toSet() fun downloadingIndices(): Set<Int> = onDownloadSet.toSet()
@Synchronized @Synchronized
fun isRun(): Boolean { fun isRun(): Boolean {
return waitDownloadSet.isNotEmpty() || onDownloadSet.isNotEmpty() || isLoading return queue.waitingCount() > 0 || onDownloadSet.isNotEmpty() || isLoading
} }
@Synchronized @Synchronized
@@ -309,7 +378,7 @@ object CacheBook {
@Synchronized @Synchronized
fun stop() { fun stop() {
waitDownloadSet.clear() queue.clear()
pausedDownloadSet.clear() pausedDownloadSet.clear()
chapterTasks.clear() chapterTasks.clear()
tasks.clear() tasks.clear()
@@ -322,18 +391,39 @@ object CacheBook {
@Synchronized @Synchronized
fun addDownload(start: Int, end: Int) { fun addDownload(start: Int, end: Int) {
addDownloads(start..end) addRequest(
CacheDownloadRequest(
book.bookUrl,
ChapterSelection.Range(start, end),
CacheDownloadSource.ReadPreload,
)
)
} }
@Synchronized @Synchronized
fun addDownloads(indices: Iterable<Int>) { fun addDownloads(indices: Iterable<Int>) {
val values = indices.toSet()
if (values.isEmpty()) return
addRequest(
CacheDownloadRequest(
book.bookUrl,
ChapterSelection.Indices(values),
CacheDownloadSource.Manual,
)
)
}
@Synchronized
fun addRequest(request: CacheDownloadRequest) {
isStopped = false isStopped = false
for (i in indices) { when (val selection = request.selection) {
pausedDownloadSet.remove(i) is ChapterSelection.Range -> {
if (!onDownloadSet.contains(i)) { pausedDownloadSet.removeAll { it in selection.start..selection.end }
waitDownloadSet.add(i)
} }
is ChapterSelection.Indices -> selection.values.forEach { pausedDownloadSet.remove(it) }
is ChapterSelection.Single -> pausedDownloadSet.remove(selection.index)
} }
queue.enqueue(request)
cacheBookMap[book.bookUrl] = this cacheBookMap[book.bookUrl] = this
isLoading = false isLoading = false
notifyDownloadSetChanged() notifyDownloadSetChanged()
@@ -351,6 +441,7 @@ object CacheBook {
successDownloadSet.add(chapter.primaryStr()) successDownloadSet.add(chapter.primaryStr())
errorDownloadMap.remove(chapter.primaryStr()) errorDownloadMap.remove(chapter.primaryStr())
errorIndexMap[book.bookUrl]?.remove(chapter.index) errorIndexMap[book.bookUrl]?.remove(chapter.index)
stateStore.markSuccess(book.bookUrl, chapter.index)
notifyDownloadSetChanged() notifyDownloadSetChanged()
notifyErrorChanged() notifyErrorChanged()
_cacheSuccessFlow.tryEmit(chapter) _cacheSuccessFlow.tryEmit(chapter)
@@ -363,6 +454,7 @@ object CacheBook {
errorDownloadMap.merge(chapter.primaryStr(), 1) { old, inc -> old + inc } errorDownloadMap.merge(chapter.primaryStr(), 1) { old, inc -> old + inc }
errorIndexMap.getOrPut(book.bookUrl) { ConcurrentHashMap.newKeySet() } errorIndexMap.getOrPut(book.bookUrl) { ConcurrentHashMap.newKeySet() }
.add(chapter.index) .add(chapter.index)
stateStore.markFailed(book.bookUrl, chapter.index)
} }
onDownloadSet.remove(chapter.index) onDownloadSet.remove(chapter.index)
chapterTasks.remove(chapter.index) chapterTasks.remove(chapter.index)
@@ -372,7 +464,7 @@ object CacheBook {
private fun onPostError(chapter: BookChapter, error: Throwable) { private fun onPostError(chapter: BookChapter, error: Throwable) {
val retryCount = errorDownloadMap[chapter.primaryStr()] ?: 0 val retryCount = errorDownloadMap[chapter.primaryStr()] ?: 0
if (retryCount < 3 && !isStopped) { if (retryCount < 3 && !isStopped) {
waitDownloadSet.add(chapter.index) queue.enqueue(ChapterSelection.Single(chapter.index))
} else { } else {
AppLog.put("下载${book.name}-${chapter.title}失败\n${error.localizedMessage}", error) AppLog.put("下载${book.name}-${chapter.title}失败\n${error.localizedMessage}", error)
} }
@@ -391,14 +483,16 @@ object CacheBook {
private fun onCancel(index: Int) { private fun onCancel(index: Int) {
onDownloadSet.remove(index) onDownloadSet.remove(index)
chapterTasks.remove(index) chapterTasks.remove(index)
if (!isStopped && !pausedDownloadSet.remove(index)) waitDownloadSet.add(index) if (!isStopped && !pausedDownloadSet.remove(index)) {
queue.enqueue(ChapterSelection.Single(index))
}
notifyDownloadSetChanged() notifyDownloadSetChanged()
} }
@Synchronized @Synchronized
private fun onFinally() { private fun onFinally() {
val bookUrl = book.bookUrl val bookUrl = book.bookUrl
if (waitDownloadSet.isEmpty() && onDownloadSet.isEmpty()) { if (queue.waitingCount() == 0 && onDownloadSet.isEmpty()) {
CacheBook.onTaskRemoved(bookUrl) CacheBook.onTaskRemoved(bookUrl)
} else { } else {
CacheBook.onTaskQueuesChanged(bookUrl) CacheBook.onTaskQueuesChanged(bookUrl)
@@ -408,7 +502,7 @@ object CacheBook {
@Synchronized @Synchronized
fun removeDownload(index: Int): Boolean { fun removeDownload(index: Int): Boolean {
val removedWaiting = waitDownloadSet.remove(index) val removedWaiting = queue.removeChapter(index)
val task = chapterTasks.remove(index) val task = chapterTasks.remove(index)
val removedRunning = onDownloadSet.contains(index) || task != null val removedRunning = onDownloadSet.contains(index) || task != null
if (removedRunning) { if (removedRunning) {
@@ -420,8 +514,8 @@ object CacheBook {
} }
if (!removedWaiting && !removedRunning) return false if (!removedWaiting && !removedRunning) return false
notifyDownloadSetChanged() notifyDownloadSetChanged()
if (waitDownloadSet.isEmpty() && onDownloadSet.isEmpty()) { if (queue.waitingCount() == 0 && onDownloadSet.isEmpty()) {
CacheBook.onTaskRemoved(book.bookUrl) CacheBook.onTaskRemoved(book.bookUrl, clearState = true)
} else { } else {
CacheBook.onTaskQueuesChanged(book.bookUrl) CacheBook.onTaskQueuesChanged(book.bookUrl)
} }
@@ -433,42 +527,39 @@ object CacheBook {
*/ */
@Synchronized @Synchronized
fun download(scope: CoroutineScope, context: CoroutineContext) { fun download(scope: CoroutineScope, context: CoroutineContext) {
val chapterIndex = waitDownloadSet.firstOrNull() val candidate = queue.next(book.bookUrl, onDownloadSet)
if (chapterIndex == null) { if (candidate == null) {
if (!isLoading && onDownloadSet.isEmpty()) { if (!isLoading && onDownloadSet.isEmpty()) {
CacheBook.onTaskRemoved(book.bookUrl) CacheBook.onTaskRemoved(book.bookUrl)
} }
return return
} }
val chapterIndex = candidate.chapterIndex
if (onDownloadSet.contains(chapterIndex)) { if (onDownloadSet.contains(chapterIndex)) {
waitDownloadSet.remove(chapterIndex)
return return
} }
val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, chapterIndex) ?: run { val chapter = repository.getChapter(book.bookUrl, chapterIndex) ?: run {
waitDownloadSet.remove(chapterIndex)
return return
} }
if (chapter.isVolume) { if (chapter.isVolume) {
postEvent(EventBus.SAVE_CONTENT, Pair(book, chapter))
_cacheSuccessFlow.tryEmit(chapter) _cacheSuccessFlow.tryEmit(chapter)
waitDownloadSet.remove(chapterIndex)
return return
} }
if (BookHelp.hasImageContent(book, chapter)) { if (repository.hasImageContent(book, chapter)) {
waitDownloadSet.remove(chapterIndex)
return return
} }
waitDownloadSet.remove(chapterIndex)
onDownloadSet.add(chapterIndex) onDownloadSet.add(chapterIndex)
notifyDownloadSetChanged() notifyDownloadSetChanged()
if (BookHelp.hasContent(book, chapter)) { if (repository.hasContent(book, chapter)) {
val task = Coroutine.async(scope, context, executeContext = context) { val task = repository.saveCachedImagesTask(
BookHelp.getContent(book, chapter)?.let { scope = scope,
BookHelp.saveImages(bookSource, book, chapter, it, 1) context = context,
} bookSource = bookSource,
}.onSuccess { book = book,
chapter = chapter,
).onSuccess {
onSuccess(chapter) onSuccess(chapter)
}.onError { }.onError {
onPreError(chapter, it) onPreError(chapter, it)
@@ -485,14 +576,14 @@ object CacheBook {
return return
} }
val task = WebBook.getContent( val task = repository.downloadContentTask(
scope, scope = scope,
bookSource, bookSource = bookSource,
book, book = book,
chapter, chapter = chapter,
context = context, context = context,
start = CoroutineStart.LAZY, start = CoroutineStart.LAZY,
executeContext = context executeContext = context,
).onSuccess { content -> ).onSuccess { content ->
onSuccess(chapter) onSuccess(chapter)
downloadFinish(chapter, content) downloadFinish(chapter, content)
@@ -515,11 +606,11 @@ object CacheBook {
suspend fun downloadAwait(chapter: BookChapter): String { suspend fun downloadAwait(chapter: BookChapter): String {
synchronized(this) { synchronized(this) {
onDownloadSet.add(chapter.index) onDownloadSet.add(chapter.index)
waitDownloadSet.remove(chapter.index) queue.removeChapter(chapter.index)
notifyDownloadSetChanged() notifyDownloadSetChanged()
} }
try { try {
val content = WebBook.getContentAwait(bookSource, book, chapter) val content = repository.downloadContentAwait(bookSource, book, chapter)
onSuccess(chapter) onSuccess(chapter)
ReadBook.downloadedChapters.add(chapter.index) ReadBook.downloadedChapters.add(chapter.index)
ReadBook.downloadFailChapters.remove(chapter.index) ReadBook.downloadFailChapters.remove(chapter.index)
@@ -544,15 +635,16 @@ object CacheBook {
) { ) {
if (onDownloadSet.contains(chapter.index)) return if (onDownloadSet.contains(chapter.index)) return
onDownloadSet.add(chapter.index) onDownloadSet.add(chapter.index)
waitDownloadSet.remove(chapter.index) queue.removeChapter(chapter.index)
notifyDownloadSetChanged() notifyDownloadSetChanged()
WebBook.getContent( repository.downloadContentTask(
scope, scope = scope,
bookSource, bookSource = bookSource,
book, book = book,
chapter, chapter = chapter,
start = CoroutineStart.LAZY, start = CoroutineStart.LAZY,
context = IO,
executeContext = IO, executeContext = IO,
semaphore = semaphore semaphore = semaphore
).onSuccess { content -> ).onSuccess { content ->
@@ -579,15 +671,15 @@ object CacheBook {
resetPageOffset: Boolean = false, resetPageOffset: Boolean = false,
canceled: Boolean = false canceled: Boolean = false
) { ) {
if (ReadBook.book?.bookUrl == book.bookUrl) { ReadingCacheEvents.emit(
ReadBook.contentLoadFinish( ReadingCacheEvent.ContentReady(
book = book, book = book,
chapter = chapter, chapter = chapter,
content = content, content = content,
resetPageOffset = resetPageOffset, resetPageOffset = resetPageOffset,
canceled = canceled canceled = canceled,
) )
} )
} }
} }
} }
@@ -26,6 +26,8 @@ import io.legado.app.help.config.ReadBookConfig
import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.coroutine.Coroutine
import io.legado.app.help.globalExecutor import io.legado.app.help.globalExecutor
import io.legado.app.model.localBook.TextFile import io.legado.app.model.localBook.TextFile
import io.legado.app.model.cache.ReadingCacheEvent
import io.legado.app.model.cache.ReadingCacheEvents
import io.legado.app.model.webBook.WebBook import io.legado.app.model.webBook.WebBook
import io.legado.app.service.BaseReadAloudService import io.legado.app.service.BaseReadAloudService
import io.legado.app.service.CacheBookService import io.legado.app.service.CacheBookService
@@ -88,6 +90,26 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
private val nextChapterLoadingLock = Mutex() private val nextChapterLoadingLock = Mutex()
var readStartTime: Long = System.currentTimeMillis() var readStartTime: Long = System.currentTimeMillis()
init {
launch {
ReadingCacheEvents.events.collect { event ->
when (event) {
is ReadingCacheEvent.ContentReady -> {
if (book?.bookUrl == event.book.bookUrl) {
contentLoadFinish(
book = event.book,
chapter = event.chapter,
content = event.content,
resetPageOffset = event.resetPageOffset,
canceled = event.canceled,
)
}
}
}
}
}
}
/* 跳转进度前进度记录 */ /* 跳转进度前进度记录 */
var lastBookProgress: BookProgress? = null var lastBookProgress: BookProgress? = null
@@ -0,0 +1,46 @@
package io.legado.app.model.cache
data class CacheDownloadRequest(
val bookUrl: String,
val selection: ChapterSelection,
val source: CacheDownloadSource = CacheDownloadSource.Manual,
)
sealed interface ChapterSelection {
data class Range(val start: Int, val end: Int) : ChapterSelection
data class Indices(val values: Set<Int>) : ChapterSelection
data class Single(val index: Int) : ChapterSelection
}
enum class CacheDownloadSource {
Manual,
Batch,
ReadPreload,
}
data class CacheDownloadCandidate(
val bookUrl: String,
val chapterIndex: Int,
)
data class CacheDownloadQueueSnapshot(
val waitingCount: Int,
)
data class CacheDownloadState(
val isRunning: Boolean = false,
val totalWaiting: Int = 0,
val totalRunning: Int = 0,
val totalSuccess: Int = 0,
val totalFailure: Int = 0,
val books: Map<String, CacheBookDownloadState> = emptyMap(),
)
data class CacheBookDownloadState(
val bookUrl: String,
val waitingCount: Int = 0,
val runningIndices: Set<Int> = emptySet(),
val failedIndices: Set<Int> = emptySet(),
val successIndices: Set<Int> = emptySet(),
val successCount: Int = 0,
)
@@ -0,0 +1,118 @@
package io.legado.app.model.cache
class CacheDownloadQueue {
private data class RangeCursor(
val start: Int,
val end: Int,
var next: Int = start,
) {
fun contains(index: Int): Boolean = index in next..end
fun remainingCount(
emittedIndices: Set<Int>,
removedIndices: Set<Int>,
): Int {
if (next > end) return 0
val rawCount = end - next + 1
val emittedCount = emittedIndices.count { it in next..end }
val removedCount = removedIndices.count { it in next..end && it !in emittedIndices }
val excludedCount = emittedCount + removedCount
return rawCount - excludedCount
}
}
private val ranges = ArrayDeque<RangeCursor>()
private val indices = linkedSetOf<Int>()
private val emittedIndices = hashSetOf<Int>()
private val removedIndices = hashSetOf<Int>()
fun enqueue(request: CacheDownloadRequest) {
enqueue(request.selection)
}
fun enqueue(selection: ChapterSelection) {
when (selection) {
is ChapterSelection.Range -> addRange(selection.start, selection.end)
is ChapterSelection.Indices -> addIndices(selection.values)
is ChapterSelection.Single -> addIndex(selection.index)
}
}
fun next(bookUrl: String, runningIndices: Set<Int>): CacheDownloadCandidate? {
while (indices.isNotEmpty()) {
val index = indices.first()
indices.remove(index)
if (index in runningIndices || index in removedIndices) continue
emittedIndices.add(index)
return CacheDownloadCandidate(bookUrl, index)
}
while (ranges.isNotEmpty()) {
val cursor = ranges.first()
while (cursor.next <= cursor.end) {
val index = cursor.next++
if (index in removedIndices || index in runningIndices) continue
if (emittedIndices.add(index)) {
return CacheDownloadCandidate(bookUrl, index)
}
}
ranges.removeFirst()
}
return null
}
fun removeChapter(index: Int): Boolean {
val removed = indices.remove(index) || isWaiting(index)
removedIndices.add(index)
return removed
}
fun clear() {
ranges.clear()
indices.clear()
emittedIndices.clear()
removedIndices.clear()
}
fun snapshot(): CacheDownloadQueueSnapshot {
return CacheDownloadQueueSnapshot(waitingCount = waitingCount())
}
fun waitingCount(): Int {
val indexCount = indices.count { it !in emittedIndices && it !in removedIndices }
val rangeCount = ranges.sumOf { it.remainingCount(emittedIndices, removedIndices) }
return indexCount + rangeCount
}
fun isWaiting(index: Int): Boolean {
if (index in emittedIndices || index in removedIndices) return false
return indices.contains(index) || ranges.any { it.contains(index) }
}
fun waitingIndices(): Set<Int> {
return buildSet {
indices.filterTo(this) { it !in emittedIndices && it !in removedIndices }
ranges.forEach { cursor ->
for (index in cursor.next..cursor.end) {
if (index !in emittedIndices && index !in removedIndices) add(index)
}
}
}
}
private fun addRange(start: Int, end: Int) {
if (end < start) return
removedIndices.removeAll { it in start..end }
ranges.add(RangeCursor(start, end))
}
private fun addIndices(values: Iterable<Int>) {
values.forEach { addIndex(it) }
}
private fun addIndex(index: Int) {
emittedIndices.remove(index)
removedIndices.remove(index)
indices.add(index)
}
}
@@ -0,0 +1,72 @@
package io.legado.app.model.cache
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookSource
import io.legado.app.help.book.BookHelp
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.model.webBook.WebBook
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.sync.Semaphore
import kotlin.coroutines.CoroutineContext
class CacheDownloadRepository {
fun getChapter(bookUrl: String, index: Int): BookChapter? {
return appDb.bookChapterDao.getChapter(bookUrl, index)
}
fun hasImageContent(book: Book, chapter: BookChapter): Boolean {
return BookHelp.hasImageContent(book, chapter)
}
fun hasContent(book: Book, chapter: BookChapter): Boolean {
return BookHelp.hasContent(book, chapter)
}
fun saveCachedImagesTask(
scope: CoroutineScope,
context: CoroutineContext,
bookSource: BookSource,
book: Book,
chapter: BookChapter,
): Coroutine<Unit> {
return Coroutine.async(scope, context, executeContext = context) {
BookHelp.getContent(book, chapter)?.let {
BookHelp.saveImages(bookSource, book, chapter, it, 1)
}
}
}
fun downloadContentTask(
scope: CoroutineScope,
bookSource: BookSource,
book: Book,
chapter: BookChapter,
context: CoroutineContext,
start: CoroutineStart = CoroutineStart.LAZY,
executeContext: CoroutineContext = context,
semaphore: Semaphore? = null,
): Coroutine<String> {
return WebBook.getContent(
scope = scope,
bookSource = bookSource,
book = book,
bookChapter = chapter,
context = context,
start = start,
executeContext = executeContext,
semaphore = semaphore,
)
}
suspend fun downloadContentAwait(
bookSource: BookSource,
book: Book,
chapter: BookChapter,
): String {
return WebBook.getContentAwait(bookSource, book, chapter)
}
}
@@ -0,0 +1,92 @@
package io.legado.app.model.cache
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
class CacheDownloadStateStore {
private val _stateFlow = MutableStateFlow(CacheDownloadState())
val stateFlow = _stateFlow.asStateFlow()
val state: CacheDownloadState
get() = _stateFlow.value
fun updateBookQueue(
bookUrl: String,
waitingCount: Int,
runningIndices: Set<Int>,
) {
updateBook(bookUrl) { current ->
current.copy(
waitingCount = waitingCount,
runningIndices = runningIndices,
)
}
}
fun markSuccess(bookUrl: String, chapterIndex: Int) {
updateBook(bookUrl) { current ->
val successIndices = current.successIndices + chapterIndex
current.copy(
runningIndices = current.runningIndices - chapterIndex,
failedIndices = current.failedIndices - chapterIndex,
successIndices = successIndices,
successCount = successIndices.size,
)
}
}
fun markFailed(bookUrl: String, chapterIndex: Int) {
updateBook(bookUrl) { current ->
current.copy(
runningIndices = current.runningIndices - chapterIndex,
failedIndices = current.failedIndices + chapterIndex,
)
}
}
fun clearFailure(bookUrl: String, chapterIndex: Int) {
updateBook(bookUrl) { current ->
current.copy(failedIndices = current.failedIndices - chapterIndex)
}
}
fun removeBook(bookUrl: String) {
_stateFlow.update { state ->
state.copy(books = state.books - bookUrl).recalculate()
}
}
fun clear() {
_stateFlow.value = CacheDownloadState()
}
fun bookState(bookUrl: String): CacheBookDownloadState? {
return state.books[bookUrl]
}
private fun updateBook(
bookUrl: String,
transform: (CacheBookDownloadState) -> CacheBookDownloadState,
) {
_stateFlow.update { state ->
val current = state.books[bookUrl] ?: CacheBookDownloadState(bookUrl)
state.copy(books = state.books + (bookUrl to transform(current))).recalculate()
}
}
private fun CacheDownloadState.recalculate(): CacheDownloadState {
val totalWaiting = books.values.sumOf { it.waitingCount }
val totalRunning = books.values.sumOf { it.runningIndices.size }
val totalFailure = books.values.sumOf { it.failedIndices.size }
val totalSuccess = books.values.sumOf { it.successCount }
return copy(
isRunning = totalWaiting > 0 || totalRunning > 0,
totalWaiting = totalWaiting,
totalRunning = totalRunning,
totalFailure = totalFailure,
totalSuccess = totalSuccess,
)
}
}
@@ -0,0 +1,25 @@
package io.legado.app.model.cache
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
sealed interface ReadingCacheEvent {
data class ContentReady(
val book: Book,
val chapter: BookChapter,
val content: String,
val resetPageOffset: Boolean,
val canceled: Boolean,
) : ReadingCacheEvent
}
object ReadingCacheEvents {
private val _events = MutableSharedFlow<ReadingCacheEvent>(extraBufferCapacity = 32)
val events = _events.asSharedFlow()
fun emit(event: ReadingCacheEvent) {
_events.tryEmit(event)
}
}
@@ -7,17 +7,18 @@ import io.legado.app.R
import io.legado.app.base.BaseService import io.legado.app.base.BaseService
import io.legado.app.constant.AppConst import io.legado.app.constant.AppConst
import io.legado.app.constant.AppLog import io.legado.app.constant.AppLog
import io.legado.app.constant.EventBus
import io.legado.app.constant.IntentAction import io.legado.app.constant.IntentAction
import io.legado.app.constant.NotificationId import io.legado.app.constant.NotificationId
import io.legado.app.data.appDb import io.legado.app.data.appDb
import io.legado.app.help.book.update import io.legado.app.help.book.update
import io.legado.app.model.CacheBook import io.legado.app.model.CacheBook
import io.legado.app.model.cache.CacheDownloadRequest
import io.legado.app.model.cache.CacheDownloadSource
import io.legado.app.model.cache.ChapterSelection
import io.legado.app.model.webBook.WebBook import io.legado.app.model.webBook.WebBook
import io.legado.app.ui.config.otherConfig.OtherConfig import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.ui.main.MainActivity import io.legado.app.ui.main.MainActivity
import io.legado.app.utils.activityPendingIntent import io.legado.app.utils.activityPendingIntent
import io.legado.app.utils.postEvent
import io.legado.app.utils.servicePendingIntent import io.legado.app.utils.servicePendingIntent
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher
@@ -75,7 +76,6 @@ class CacheBookService : BaseService() {
delay(1000) delay(1000)
notificationContent = CacheBook.downloadSummary notificationContent = CacheBook.downloadSummary
upCacheBookNotification() upCacheBookNotification()
postEvent(EventBus.UP_DOWNLOAD, "")
} }
} }
} }
@@ -111,18 +111,39 @@ class CacheBookService : BaseService() {
cachePool.close() cachePool.close()
CacheBook.close() CacheBook.close()
super.onDestroy() super.onDestroy()
postEvent(EventBus.UP_DOWNLOAD, "")
} }
private fun addDownloadData(bookUrl: String?, indices: List<Int>) { private fun addDownloadData(bookUrl: String?, indices: Iterable<Int>) {
bookUrl ?: return bookUrl ?: return
if (indices.isEmpty()) return val values = indices.toSet()
if (values.isEmpty()) return
addDownloadRequest(
CacheDownloadRequest(
bookUrl = bookUrl,
selection = ChapterSelection.Indices(values),
source = CacheDownloadSource.Manual,
)
)
}
private fun addDownloadData(bookUrl: String?, start: Int, end: Int) {
bookUrl ?: return
if (end < start) return
addDownloadRequest(
CacheDownloadRequest(
bookUrl = bookUrl,
selection = ChapterSelection.Range(start, end),
source = CacheDownloadSource.Manual,
)
)
}
private fun addDownloadRequest(request: CacheDownloadRequest) {
execute { execute {
val cacheBook = CacheBook.getOrCreate(bookUrl) ?: return@execute val cacheBook = CacheBook.getOrCreate(request.bookUrl) ?: return@execute
val book = cacheBook.book val book = cacheBook.book
val chapterCount = appDb.bookChapterDao.getChapterCount(bookUrl) val chapterCount = appDb.bookChapterDao.getChapterCount(request.bookUrl)
if (chapterCount == 0) { if (chapterCount == 0) {
cacheBook.setLoading() cacheBook.setLoading()
@@ -132,7 +153,7 @@ class CacheBookService : BaseService() {
kotlin.runCatching { kotlin.runCatching {
WebBook.getBookInfoAwait(cacheBook.bookSource, book) WebBook.getBookInfoAwait(cacheBook.bookSource, book)
}.onFailure { }.onFailure {
removeDownload(bookUrl) removeDownload(request.bookUrl)
AppLog.put( AppLog.put(
"$name》目录为空且加载详情页失败\n${it.localizedMessage}", "$name》目录为空且加载详情页失败\n${it.localizedMessage}",
it, it,
@@ -147,7 +168,7 @@ class CacheBookService : BaseService() {
book.totalChapterNum = 0 book.totalChapterNum = 0
book.update() book.update()
} }
removeDownload(bookUrl) removeDownload(request.bookUrl)
AppLog.put( AppLog.put(
"$name》目录为空且加载目录失败\n${it.localizedMessage}", "$name》目录为空且加载目录失败\n${it.localizedMessage}",
it, it,
@@ -163,7 +184,7 @@ class CacheBookService : BaseService() {
} }
//添加章节到下载队列 //添加章节到下载队列
cacheBook.addDownloads(indices) cacheBook.addRequest(request)
notificationContent = CacheBook.downloadSummary notificationContent = CacheBook.downloadSummary
upCacheBookNotification() upCacheBookNotification()
@@ -174,14 +195,9 @@ class CacheBookService : BaseService() {
} }
} }
private fun addDownloadData(bookUrl: String?, start: Int, end: Int) {
addDownloadData(bookUrl, (start..end).toList())
}
private fun removeDownload(bookUrl: String?) { private fun removeDownload(bookUrl: String?) {
CacheBook.cacheBookMap[bookUrl]?.stop() CacheBook.cacheBookMap[bookUrl]?.stop()
postEvent(EventBus.UP_DOWNLOAD, "")
if (downloadJob == null && CacheBook.isRun) { if (downloadJob == null && CacheBook.isRun) {
download() download()
return return
@@ -19,6 +19,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.isActive
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
@@ -26,6 +28,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.min import kotlin.math.min
data class BookCacheManageUiState( data class BookCacheManageUiState(
@@ -94,6 +97,11 @@ class BookCacheManageViewModel(
private val clearBookCacheUseCase: ClearBookCacheUseCase, private val clearBookCacheUseCase: ClearBookCacheUseCase,
) : BaseViewModel(application) { ) : BaseViewModel(application) {
private companion object {
const val DOWNLOAD_BATCH_SIZE = 512
const val DOWNLOAD_STATUS_REFRESH_INTERVAL_MILLIS = 2_000L
}
private val _uiState = MutableStateFlow(BookCacheManageUiState()) private val _uiState = MutableStateFlow(BookCacheManageUiState())
val uiState = _uiState.asStateFlow() val uiState = _uiState.asStateFlow()
@@ -103,6 +111,9 @@ class BookCacheManageViewModel(
private var observeJob: Job? = null private var observeJob: Job? = null
private var fullReloadJob: Job? = null private var fullReloadJob: Job? = null
private val bookReloadJobs = hashMapOf<String, Job>() private val bookReloadJobs = hashMapOf<String, Job>()
private val pendingDownloadRefreshBookUrls = ConcurrentHashMap.newKeySet<String>()
@Volatile
private var pendingDownloadSummaryRefresh = false
fun onIntent(intent: BookCacheManageIntent) { fun onIntent(intent: BookCacheManageIntent) {
when (intent) { when (intent) {
@@ -140,32 +151,24 @@ class BookCacheManageViewModel(
} }
viewModelScope.launch { viewModelScope.launch {
CacheBook.cacheSuccessFlow.collect { chapter -> CacheBook.cacheSuccessFlow.collect { chapter ->
scheduleBookReload(chapter.bookUrl) scheduleDownloadStatusRefresh(chapter.bookUrl)
} }
} }
viewModelScope.launch { viewModelScope.launch {
CacheBook.downloadingIndicesFlow.collect { state -> CacheBook.downloadStateFlow.collect { state ->
scheduleBookReload(state.first) state.books.keys.forEach { scheduleDownloadStatusRefresh(it) }
pendingDownloadSummaryRefresh = true
} }
} }
viewModelScope.launch { viewModelScope.launch {
CacheBook.queueChangedFlow.collect { bookUrl -> CacheBook.queueChangedFlow.collect { bookUrl ->
scheduleBookReload(bookUrl) scheduleDownloadStatusRefresh(bookUrl)
} }
} }
viewModelScope.launch { viewModelScope.launch {
CacheBook.downloadErrorFlow.collect { state -> while (isActive) {
scheduleBookReload(state.first) delay(DOWNLOAD_STATUS_REFRESH_INTERVAL_MILLIS)
} flushDownloadStatusRefresh()
}
viewModelScope.launch {
CacheBook.downloadSummaryFlow.collect {
_uiState.update {
it.copy(
downloadSummary = buildDownloadSummary(it.shelfBooks + it.notShelfBooks),
version = it.version + 1,
)
}
} }
} }
} }
@@ -216,6 +219,31 @@ class BookCacheManageViewModel(
} }
} }
private fun scheduleDownloadStatusRefresh(bookUrl: String) {
if (bookUrl.isNotBlank()) {
pendingDownloadRefreshBookUrls.add(bookUrl)
}
pendingDownloadSummaryRefresh = true
}
private suspend fun flushDownloadStatusRefresh() {
val bookUrls = pendingDownloadRefreshBookUrls.toList()
bookUrls.forEach { pendingDownloadRefreshBookUrls.remove(it) }
val shouldRefreshSummary = pendingDownloadSummaryRefresh || bookUrls.isNotEmpty()
pendingDownloadSummaryRefresh = false
bookUrls.forEach { bookUrl ->
reloadBook(bookUrl)
}
if (shouldRefreshSummary) {
_uiState.update {
it.copy(
downloadSummary = buildDownloadSummary(it.shelfBooks + it.notShelfBooks),
version = it.version + 1,
)
}
}
}
private fun scheduleBookReload(bookUrl: String, debounceMillis: Long = 80) { private fun scheduleBookReload(bookUrl: String, debounceMillis: Long = 80) {
if (bookUrl.isBlank()) return if (bookUrl.isBlank()) return
bookReloadJobs.remove(bookUrl)?.cancel() bookReloadJobs.remove(bookUrl)?.cancel()
@@ -278,8 +306,7 @@ class BookCacheManageViewModel(
private fun buildBookItem(book: Book): BookCacheBookItem? { private fun buildBookItem(book: Book): BookCacheBookItem? {
val cacheFiles = BookHelp.getChapterFiles(book) val cacheFiles = BookHelp.getChapterFiles(book)
val model = CacheBook.cacheBookMap[book.bookUrl] val model = CacheBook.cacheBookMap[book.bookUrl]
val waitingIndices = model?.waitingIndices().orEmpty() val (waitingCount, downloadingCount) = model?.queueCounts() ?: (0 to 0)
val downloadingIndices = model?.downloadingIndices().orEmpty()
val errorIndices = errorIndices(book.bookUrl) val errorIndices = errorIndices(book.bookUrl)
val totalCount = bookChapterDao.getChapterCount(book.bookUrl) val totalCount = bookChapterDao.getChapterCount(book.bookUrl)
val cachedFileCount = cacheFiles.count { it.endsWith(".nb") } val cachedFileCount = cacheFiles.count { it.endsWith(".nb") }
@@ -293,8 +320,8 @@ class BookCacheManageViewModel(
author = book.getRealAuthor(), author = book.getRealAuthor(),
totalCount = totalCount, totalCount = totalCount,
cachedCount = cachedCount, cachedCount = cachedCount,
waitingCount = waitingIndices.size, waitingCount = waitingCount,
downloadingCount = downloadingIndices.size, downloadingCount = downloadingCount,
errorCount = errorIndices.size, errorCount = errorIndices.size,
isNotShelf = book.isNotShelf, isNotShelf = book.isNotShelf,
) )
@@ -305,8 +332,6 @@ class BookCacheManageViewModel(
val chapters = bookChapterDao.getChapterCacheInfoList(bookUrl) val chapters = bookChapterDao.getChapterCacheInfoList(bookUrl)
val cacheFiles = BookHelp.getChapterFiles(book) val cacheFiles = BookHelp.getChapterFiles(book)
val model = CacheBook.cacheBookMap[bookUrl] val model = CacheBook.cacheBookMap[bookUrl]
val waitingIndices = model?.waitingIndices().orEmpty()
val downloadingIndices = model?.downloadingIndices().orEmpty()
val errorIndices = errorIndices(bookUrl) val errorIndices = errorIndices(bookUrl)
return chapters.map { chapter -> return chapters.map { chapter ->
BookCacheChapterItem( BookCacheChapterItem(
@@ -314,8 +339,8 @@ class BookCacheManageViewModel(
title = chapter.title, title = chapter.title,
index = chapter.index, index = chapter.index,
isCached = cacheFiles.contains(chapter.getFileName()) || chapter.isVolume, isCached = cacheFiles.contains(chapter.getFileName()) || chapter.isVolume,
isWaiting = waitingIndices.contains(chapter.index), isWaiting = model?.isWaiting(chapter.index) == true,
isDownloading = downloadingIndices.contains(chapter.index), isDownloading = model?.isDownloading(chapter.index) == true,
isError = errorIndices.contains(chapter.index), isError = errorIndices.contains(chapter.index),
) )
} }
@@ -377,10 +402,14 @@ class BookCacheManageViewModel(
private fun startAllDownloads() { private fun startAllDownloads() {
val items = uiState.value.shelfBooks + uiState.value.notShelfBooks val items = uiState.value.shelfBooks + uiState.value.notShelfBooks
execute { execute {
items.sumOf { item -> var count = 0
val chapterIndices = downloadableChapterIndices(item.bookUrl) items.forEach { item ->
cacheBookChaptersUseCase.execute(item.bookUrl, chapterIndices) downloadableChapterIndexBatches(item.bookUrl).forEach { chapterIndices ->
count += cacheBookChaptersUseCase.execute(item.bookUrl, chapterIndices)
}
currentCoroutineContext().ensureActive()
} }
count
}.onSuccess { count -> }.onSuccess { count ->
if (count > 0) { if (count > 0) {
_effects.tryEmit(BookCacheManageEffect.ShowMessage("已加入缓存队列: $count")) _effects.tryEmit(BookCacheManageEffect.ShowMessage("已加入缓存队列: $count"))
@@ -396,7 +425,11 @@ class BookCacheManageViewModel(
private fun startBookDownload(bookUrl: String) { private fun startBookDownload(bookUrl: String) {
execute { execute {
cacheBookChaptersUseCase.execute(bookUrl, downloadableChapterIndices(bookUrl)) var count = 0
downloadableChapterIndexBatches(bookUrl).forEach { chapterIndices ->
count += cacheBookChaptersUseCase.execute(bookUrl, chapterIndices)
}
count
}.onSuccess { count -> }.onSuccess { count ->
if (count > 0) { if (count > 0) {
_effects.tryEmit(BookCacheManageEffect.ShowMessage("已加入缓存队列: $count")) _effects.tryEmit(BookCacheManageEffect.ShowMessage("已加入缓存队列: $count"))
@@ -410,22 +443,32 @@ class BookCacheManageViewModel(
} }
} }
private fun downloadableChapterIndices(bookUrl: String): List<Int> { private fun downloadableChapterIndexBatches(
val book = bookDao.getBook(bookUrl) ?: return emptyList() bookUrl: String,
batchSize: Int = DOWNLOAD_BATCH_SIZE,
): Sequence<List<Int>> = sequence {
val book = bookDao.getBook(bookUrl) ?: return@sequence
val cacheFiles = BookHelp.getChapterFiles(book) val cacheFiles = BookHelp.getChapterFiles(book)
val model = CacheBook.cacheBookMap[bookUrl] val model = CacheBook.cacheBookMap[bookUrl]
val waitingIndices = model?.waitingIndices().orEmpty() var batch = ArrayList<Int>(batchSize)
val downloadingIndices = model?.downloadingIndices().orEmpty() for (chapter in bookChapterDao.getChapterCacheInfoList(bookUrl)) {
return bookChapterDao.getChapterCacheInfoList(bookUrl) if (
.asSequence()
.filterNot { chapter ->
chapter.isVolume || chapter.isVolume ||
cacheFiles.contains(chapter.getFileName()) || cacheFiles.contains(chapter.getFileName()) ||
waitingIndices.contains(chapter.index) || model?.isWaiting(chapter.index) == true ||
downloadingIndices.contains(chapter.index) model?.isDownloading(chapter.index) == true
) {
continue
} }
.map { it.index } batch.add(chapter.index)
.toList() if (batch.size == batchSize) {
yield(batch)
batch = ArrayList(batchSize)
}
}
if (batch.isNotEmpty()) {
yield(batch)
}
} }
private fun deleteBookCache(bookUrl: String) { private fun deleteBookCache(bookUrl: String) {
@@ -37,7 +37,9 @@ import io.legado.app.utils.cnCompare
import io.legado.app.utils.move import io.legado.app.utils.move
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.isActive
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
@@ -155,6 +157,10 @@ class BookshelfManageScreenViewModel(
private val updateBooksGroupUseCase: UpdateBooksGroupUseCase private val updateBooksGroupUseCase: UpdateBooksGroupUseCase
) : BaseViewModel(application) { ) : BaseViewModel(application) {
private companion object {
const val DOWNLOAD_STATUS_REFRESH_INTERVAL_MILLIS = 2_000L
}
private val _uiState = MutableStateFlow(BookshelfManageScreenUiState()) private val _uiState = MutableStateFlow(BookshelfManageScreenUiState())
val uiState = _uiState.asStateFlow() val uiState = _uiState.asStateFlow()
@@ -165,8 +171,11 @@ class BookshelfManageScreenViewModel(
private var booksJob: Job? = null private var booksJob: Job? = null
private var groupsJob: Job? = null private var groupsJob: Job? = null
private var cacheLoadJob: Job? = null private var cacheLoadJob: Job? = null
private val cacheCountJobs = ConcurrentHashMap<String, Job>()
private var observersStarted = false private var observersStarted = false
private val pendingDownloadStatusBookUrls = ConcurrentHashMap.newKeySet<String>()
private val pendingCacheCountRefreshBookUrls = ConcurrentHashMap.newKeySet<String>()
@Volatile
private var pendingDownloadRunningRefresh = false
fun dispatch(intent: BookshelfManageScreenIntent) { fun dispatch(intent: BookshelfManageScreenIntent) {
when (intent) { when (intent) {
@@ -367,28 +376,26 @@ class BookshelfManageScreenViewModel(
observersStarted = true observersStarted = true
viewModelScope.launch { viewModelScope.launch {
CacheBook.cacheSuccessFlow.collect { chapter -> CacheBook.cacheSuccessFlow.collect { chapter ->
onChapterCached(chapter) scheduleCacheCountRefresh(chapter.bookUrl)
} }
} }
viewModelScope.launch { viewModelScope.launch {
CacheBook.downloadingIndicesFlow.collect { (bookUrl, _) -> CacheBook.downloadStateFlow.collect { state ->
syncDownloadRunning() state.books.keys.forEach { bookUrl ->
if (bookUrl.isNotEmpty()) { scheduleDownloadStatusRefresh(bookUrl)
emitBookChanged(bookUrl)
} }
scheduleDownloadStatusRefresh()
} }
} }
viewModelScope.launch { viewModelScope.launch {
CacheBook.downloadErrorFlow.collect { (bookUrl, _) -> CacheBook.queueChangedFlow.collect { bookUrl ->
syncDownloadRunning() scheduleDownloadStatusRefresh(bookUrl)
if (bookUrl.isNotEmpty()) {
emitBookChanged(bookUrl)
}
} }
} }
viewModelScope.launch { viewModelScope.launch(Dispatchers.IO) {
CacheBook.downloadSummaryFlow.collect { while (isActive) {
syncDownloadRunning() delay(DOWNLOAD_STATUS_REFRESH_INTERVAL_MILLIS)
flushDownloadStatusRefresh()
} }
} }
viewModelScope.launch { viewModelScope.launch {
@@ -432,8 +439,6 @@ class BookshelfManageScreenViewModel(
private fun loadCacheCounts(books: List<Book>) { private fun loadCacheCounts(books: List<Book>) {
cacheLoadJob?.cancel() cacheLoadJob?.cancel()
cacheCountJobs.values.forEach { it.cancel() }
cacheCountJobs.clear()
cacheLoadJob = viewModelScope.launch(Dispatchers.IO) { cacheLoadJob = viewModelScope.launch(Dispatchers.IO) {
val visibleBookUrls = books.mapTo(hashSetOf()) { it.bookUrl } val visibleBookUrls = books.mapTo(hashSetOf()) { it.bookUrl }
cacheCounts.keys.toList().forEach { bookUrl -> cacheCounts.keys.toList().forEach { bookUrl ->
@@ -451,22 +456,48 @@ class BookshelfManageScreenViewModel(
} }
} }
private fun onChapterCached(chapter: BookChapter) { private fun scheduleCacheCountRefresh(bookUrl: String) {
val bookUrl = chapter.bookUrl if (bookUrl.isNotBlank()) {
scheduleCacheCountRefresh(bookUrl) pendingCacheCountRefreshBookUrls.add(bookUrl)
}
pendingDownloadRunningRefresh = true
} }
private fun scheduleCacheCountRefresh(bookUrl: String) { private fun scheduleDownloadStatusRefresh(bookUrl: String = "") {
cacheCountJobs.remove(bookUrl)?.cancel() if (bookUrl.isNotBlank()) {
cacheCountJobs[bookUrl] = viewModelScope.launch(Dispatchers.IO) { pendingDownloadStatusBookUrls.add(bookUrl)
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)
} }
pendingDownloadRunningRefresh = true
}
private suspend fun flushDownloadStatusRefresh() {
val cacheRefreshBookUrls = pendingCacheCountRefreshBookUrls.toList()
cacheRefreshBookUrls.forEach { pendingCacheCountRefreshBookUrls.remove(it) }
val statusBookUrls = pendingDownloadStatusBookUrls.toList()
statusBookUrls.forEach { pendingDownloadStatusBookUrls.remove(it) }
val shouldSyncDownloadRunning = pendingDownloadRunningRefresh ||
cacheRefreshBookUrls.isNotEmpty() ||
statusBookUrls.isNotEmpty()
pendingDownloadRunningRefresh = false
val changedBookUrls = linkedSetOf<String>()
val visibleBookUrls = uiState.value.books.mapTo(hashSetOf()) { it.bookUrl }
cacheRefreshBookUrls.forEach { bookUrl ->
if (visibleBookUrls.contains(bookUrl)) {
bookDao.getBook(bookUrl)?.let { book ->
cacheCounts[bookUrl] = calculateCacheCount(book)
changedBookUrls.add(bookUrl)
}
}
}
statusBookUrls.forEach { bookUrl ->
if (visibleBookUrls.contains(bookUrl)) {
changedBookUrls.add(bookUrl)
}
}
if (shouldSyncDownloadRunning) {
syncDownloadRunning()
}
emitBooksChanged(changedBookUrls)
} }
private fun calculateCacheCount(book: Book): Int { private fun calculateCacheCount(book: Book): Int {
@@ -496,7 +527,7 @@ class BookshelfManageScreenViewModel(
syncDownloadRunning() syncDownloadRunning()
} else { } else {
execute { execute {
cacheBookChaptersUseCase.execute(book.bookUrl, 0..book.lastChapterIndex) cacheBookChaptersUseCase.executeRange(book.bookUrl, 0, book.lastChapterIndex)
}.onFinally { }.onFinally {
syncDownloadRunning() syncDownloadRunning()
} }
@@ -873,4 +904,12 @@ class BookshelfManageScreenViewModel(
_effects.tryEmit(BookshelfManageScreenEffect.NotifyBookChanged(bookUrl)) _effects.tryEmit(BookshelfManageScreenEffect.NotifyBookChanged(bookUrl))
} }
private fun emitBooksChanged(bookUrls: Set<String>) {
if (bookUrls.isEmpty()) return
_uiState.update { it.copy(cacheVersion = it.cacheVersion + 1) }
bookUrls.forEach { bookUrl ->
_effects.tryEmit(BookshelfManageScreenEffect.NotifyBookChanged(bookUrl))
}
}
} }
@@ -303,8 +303,7 @@ abstract class BaseReadBookActivity :
val end = editEnd.text!!.toString().let { val end = editEnd.text!!.toString().let {
if (it.isEmpty()) book.totalChapterNum else it.toInt() if (it.isEmpty()) book.totalChapterNum else it.toInt()
} }
val indices = (start - 1..end - 1).toList() CacheBook.start(this@BaseReadBookActivity, book, start - 1, end - 1)
CacheBook.start(this@BaseReadBookActivity, book, indices)
} }
} }
cancelButton() cancelButton()
@@ -21,6 +21,7 @@ import io.legado.app.help.bookmark.BookmarkExporter
import io.legado.app.help.config.AppConfig import io.legado.app.help.config.AppConfig
import io.legado.app.model.CacheBook import io.legado.app.model.CacheBook
import io.legado.app.model.ReadBook import io.legado.app.model.ReadBook
import io.legado.app.model.cache.CacheBookDownloadState
import io.legado.app.model.localBook.LocalBook import io.legado.app.model.localBook.LocalBook
import io.legado.app.ui.config.readConfig.ReadConfig import io.legado.app.ui.config.readConfig.ReadConfig
import io.legado.app.ui.widget.components.importComponents.BaseImportUiState import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
@@ -89,8 +90,7 @@ data class TocDomainItem(
) )
private data class DownloadContext( private data class DownloadContext(
val downloadingPair: Pair<String, Set<Int>>, val downloadState: CacheBookDownloadState?,
val errorPair: Pair<String, Set<Int>>,
val cachedFiles: Set<String> val cachedFiles: Set<String>
) )
@@ -206,11 +206,11 @@ class TocViewModel(
.distinctUntilChanged() .distinctUntilChanged()
private val downloadContextFlow = combine( private val downloadContextFlow = combine(
CacheBook.downloadingIndicesFlow, bookState.filterNotNull().map { it.bookUrl }.distinctUntilChanged(),
CacheBook.downloadErrorFlow, CacheBook.downloadStateFlow,
_cacheFileNames _cacheFileNames
) { downloading, errors, cached -> ) { bookUrl, state, cached ->
DownloadContext(downloading, errors, cached) DownloadContext(state.books[bookUrl], cached)
} }
private val uiConfigFlow = combine( private val uiConfigFlow = combine(
@@ -263,15 +263,13 @@ class TocViewModel(
} }
} }
val (downloadingPair, errorPair, cachedFiles) = downloadCtx val runningIndices = downloadCtx.downloadState?.runningIndices.orEmpty()
val downloadingIndices = val errorIndices = downloadCtx.downloadState?.failedIndices.orEmpty()
if (downloadingPair.first == book.bookUrl) downloadingPair.second else emptySet() val cachedFiles = downloadCtx.cachedFiles
val errorIndices =
if (errorPair.first == book.bookUrl) errorPair.second else emptySet()
processedChapters.map { chapter -> processedChapters.map { chapter ->
val downloadState = when { val downloadState = when {
chapter.index in downloadingIndices -> DownloadState.DOWNLOADING chapter.index in runningIndices -> DownloadState.DOWNLOADING
chapter.index in errorIndices -> DownloadState.ERROR chapter.index in errorIndices -> DownloadState.ERROR
chapter.getFileName() in cachedFiles -> DownloadState.SUCCESS chapter.getFileName() in cachedFiles -> DownloadState.SUCCESS
else -> DownloadState.NONE else -> DownloadState.NONE
@@ -0,0 +1,78 @@
package io.legado.app.model.cache
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class CacheDownloadQueueTest {
@Test
fun rangeReturnsChaptersLazily() {
val queue = CacheDownloadQueue()
queue.enqueue(ChapterSelection.Range(2, 4))
assertEquals(3, queue.waitingCount())
assertEquals(2, queue.next("book", emptySet())?.chapterIndex)
assertEquals(2, queue.waitingCount())
assertEquals(3, queue.next("book", emptySet())?.chapterIndex)
assertEquals(4, queue.next("book", emptySet())?.chapterIndex)
assertNull(queue.next("book", emptySet()))
}
@Test
fun removeChapterSkipsChapterInsideRange() {
val queue = CacheDownloadQueue()
queue.enqueue(ChapterSelection.Range(0, 3))
assertTrue(queue.removeChapter(1))
assertEquals(listOf(0, 2, 3), drain(queue))
assertFalse(queue.isWaiting(1))
}
@Test
fun nextDoesNotReturnRunningChapter() {
val queue = CacheDownloadQueue()
queue.enqueue(ChapterSelection.Indices(setOf(1, 2)))
assertEquals(2, queue.next("book", setOf(1))?.chapterIndex)
assertNull(queue.next("book", emptySet()))
}
@Test
fun explicitRequeueCanRetryConsumedChapter() {
val queue = CacheDownloadQueue()
queue.enqueue(ChapterSelection.Range(1, 1))
assertEquals(1, queue.next("book", emptySet())?.chapterIndex)
queue.enqueue(ChapterSelection.Single(1))
assertEquals(1, queue.next("book", emptySet())?.chapterIndex)
}
@Test
fun reEnqueueRangeRestoresRemovedChapter() {
val queue = CacheDownloadQueue()
queue.enqueue(ChapterSelection.Range(0, 2))
assertTrue(queue.removeChapter(1))
assertEquals(0, queue.next("book", emptySet())?.chapterIndex)
queue.enqueue(ChapterSelection.Range(1, 1))
assertEquals(1, queue.next("book", emptySet())?.chapterIndex)
assertEquals(2, queue.next("book", emptySet())?.chapterIndex)
}
private fun drain(queue: CacheDownloadQueue): List<Int> {
val result = mutableListOf<Int>()
while (true) {
val next = queue.next("book", emptySet()) ?: break
result.add(next.chapterIndex)
}
return result
}
}
@@ -0,0 +1,61 @@
package io.legado.app.model.cache
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class CacheDownloadStateStoreTest {
@Test
fun queueAndRunningCountsAreDerivedFromBooks() {
val store = CacheDownloadStateStore()
store.updateBookQueue("a", waitingCount = 2, runningIndices = setOf(1))
store.updateBookQueue("b", waitingCount = 3, runningIndices = setOf(4, 5))
val state = store.state
assertTrue(state.isRunning)
assertEquals(5, state.totalWaiting)
assertEquals(3, state.totalRunning)
}
@Test
fun successAndFailureUpdateBookState() {
val store = CacheDownloadStateStore()
store.updateBookQueue("a", waitingCount = 0, runningIndices = setOf(1, 2))
store.markFailed("a", 1)
store.markSuccess("a", 2)
val bookState = store.state.books.getValue("a")
assertEquals(setOf(1), bookState.failedIndices)
assertEquals(emptySet<Int>(), bookState.runningIndices)
assertEquals(1, bookState.successCount)
assertEquals(1, store.state.totalFailure)
assertEquals(1, store.state.totalSuccess)
}
@Test
fun duplicateSuccessDoesNotInflateCount() {
val store = CacheDownloadStateStore()
store.markSuccess("a", 1)
store.markSuccess("a", 1)
assertEquals(1, store.state.books.getValue("a").successCount)
assertEquals(1, store.state.totalSuccess)
}
@Test
fun removeBookRecalculatesRunningState() {
val store = CacheDownloadStateStore()
store.updateBookQueue("a", waitingCount = 1, runningIndices = emptySet())
store.removeBook("a")
assertFalse(store.state.isRunning)
assertEquals(0, store.state.totalWaiting)
assertEquals(emptyMap<String, CacheBookDownloadState>(), store.state.books)
}
}