[优化] 重写下载核心

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.domain.gateway.BookCacheDownloadGateway
import io.legado.app.help.book.isLocal
import io.legado.app.model.CacheBook
import io.legado.app.model.cache.CacheDownloadRequest
import io.legado.app.model.cache.ChapterSelection
import splitties.init.appCtx
class CacheBookDownloadRepository(
private val bookDao: BookDao
) : 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>) {
val book = bookDao.getBook(bookUrl) ?: return
CacheBook.start(appCtx, book, chapterIndices)
start(
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.AppStartupGateway
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.DatabaseMaintenanceGateway
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.ui.widget.components.explore.ExploreKindUiUseCase
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.UpdateBooksGroupUseCase
import io.legado.app.domain.usecase.UploadReadingProgressUseCase
@@ -140,7 +142,12 @@ val appModule = module {
single<ReadingProgressGateway> { WebDavReadingProgressRepository() }
single<BookDomainRepository> { BookDomainRepositoryImpl(get(), get()) }
single<ExploreRepository> { ExploreRepositoryImpl(get()) }
single<SearchRepository> { SearchRepositoryImpl(get()) }
single {
SearchRepositoryImpl(get())
}
single<SearchRepository> { get<SearchRepositoryImpl>() }
single<BookSearchGateway> { get<SearchRepositoryImpl>() }
singleOf(::SearchBooksUseCase)
single<ImageLoader> {
ImageLoader.Builder(get())
@@ -1,5 +1,9 @@
package io.legado.app.domain.gateway
import io.legado.app.model.cache.CacheDownloadRequest
interface BookCacheDownloadGateway {
suspend fun start(request: CacheDownloadRequest)
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.model.CacheableBook
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(
private val bookRepository: BookDomainRepository,
@@ -31,13 +34,16 @@ class BatchCacheDownloadUseCase(
): Boolean {
if (book.isLocal) return false
if (skipAudioBooks && book.isAudio) return false
val indices = if (downloadAllChapters) {
(0..book.lastChapterIndex).toList()
} else {
(book.durChapterIndex..book.lastChapterIndex).toList()
}
if (indices.isEmpty()) return false
bookCacheDownloadGateway.start(book.bookUrl, indices)
val startIndex = if (downloadAllChapters) 0 else book.durChapterIndex
val endIndex = book.lastChapterIndex
if (endIndex < startIndex) return false
bookCacheDownloadGateway.start(
CacheDownloadRequest(
bookUrl = book.bookUrl,
selection = ChapterSelection.Range(startIndex, endIndex),
source = CacheDownloadSource.Batch,
)
)
return true
}
}
@@ -1,6 +1,9 @@
package io.legado.app.domain.usecase
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(
private val bookCacheDownloadGateway: BookCacheDownloadGateway
@@ -9,7 +12,25 @@ class CacheBookChaptersUseCase(
suspend fun execute(bookUrl: String, chapterIndices: Iterable<Int>): Int {
val indices = chapterIndices.distinct()
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
}
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 io.legado.app.constant.AppLog
import io.legado.app.constant.EventBus
import io.legado.app.constant.IntentAction
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.exception.ConcurrentException
import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.isLocal
import io.legado.app.help.coroutine.CompositeCoroutine
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.ui.config.otherConfig.OtherConfig
import io.legado.app.utils.onEachParallel
import io.legado.app.utils.postEvent
import io.legado.app.utils.startService
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@@ -75,20 +79,20 @@ object CacheBook {
if (!emitted) delay(800)
}
}.onStart {
postEvent(EventBus.UP_DOWNLOAD_STATE, "")
updateSummary()
}.onEachParallel(OtherConfig.cacheBookThreadCount.coerceAtLeast(1)) {
coroutineScope {
it.download(this, context)
}
}.onCompletion {
postEvent(EventBus.UP_DOWNLOAD_STATE, "")
updateSummary()
}.collect()
}
}
private val coordinator = CacheBookCoordinator()
private val stateStore = CacheDownloadStateStore()
val downloadStateFlow = stateStore.stateFlow
private val _cacheSuccessFlow = MutableSharedFlow<BookChapter>(extraBufferCapacity = 64)
val cacheSuccessFlow = _cacheSuccessFlow.asSharedFlow()
@@ -117,7 +121,7 @@ object CacheBook {
get() = coordinator.taskMap
fun errorIndices(bookUrl: String): Set<Int> {
return errorIndexMap[bookUrl]?.toSet().orEmpty()
return stateStore.bookState(bookUrl)?.failedIndices.orEmpty()
}
private fun collectQueueStats(): QueueStats {
@@ -168,11 +172,54 @@ object CacheBook {
}
fun start(context: Context, book: Book, selectedIndices: List<Int>) {
if (!book.isLocal && selectedIndices.isNotEmpty()) {
context.startService<CacheBookService> {
action = IntentAction.start
putExtra("bookUrl", book.bookUrl)
putIntegerArrayListExtra("indices", ArrayList(selectedIndices))
start(
context = context,
request = CacheDownloadRequest(
bookUrl = book.bookUrl,
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 {
val model = cacheBookMap.remove(bookUrl) ?: return false
model.stop()
stateStore.removeBook(bookUrl)
updateSummary()
_queueChangedFlow.tryEmit(bookUrl)
postEvent(EventBus.UP_DOWNLOAD, bookUrl)
return true
}
@@ -211,6 +258,7 @@ object CacheBook {
successDownloadSet.clear()
errorDownloadMap.clear()
errorIndexMap.clear()
stateStore.clear()
updateSummary()
}
@@ -241,15 +289,26 @@ object CacheBook {
get() = lastQueueStats.waitingCount > 0 || lastQueueStats.downloadingCount > 0
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()
_queueChangedFlow.tryEmit(bookUrl)
postEvent(EventBus.UP_DOWNLOAD, bookUrl)
}
private fun onTaskRemoved(bookUrl: String) {
private fun onTaskRemoved(bookUrl: String, clearState: Boolean = false) {
cacheBookMap.remove(bookUrl)
if (clearState) {
stateStore.removeBook(bookUrl)
}
updateSummary()
postEvent(EventBus.UP_DOWNLOAD, bookUrl)
_queueChangedFlow.tryEmit(bookUrl)
}
class CacheBookModel(
@@ -257,40 +316,50 @@ object CacheBook {
@Volatile var book: Book
) {
private val waitDownloadSet = linkedSetOf<Int>()
private val queue = CacheDownloadQueue()
private val onDownloadSet = linkedSetOf<Int>()
private val pausedDownloadSet = hashSetOf<Int>()
private val chapterTasks = hashMapOf<Int, Coroutine<*>>()
private val tasks = CompositeCoroutine()
private val repository = CacheDownloadRepository()
private var isStopped = false
private var waitingRetry = false
private var isLoading = false
init {
postEvent(EventBus.UP_DOWNLOAD, book.bookUrl)
}
private fun notifyDownloadSetChanged() {
_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() {
val errors = errorIndexMap[book.bookUrl]?.toSet() ?: emptySet()
val errors = errorIndices(book.bookUrl)
_downloadErrorFlow.tryEmit(book.bookUrl to errors)
}
@Synchronized
fun queueCounts(): Pair<Int, Int> = waitDownloadSet.size to onDownloadSet.size
fun queueCounts(): Pair<Int, Int> = queue.waitingCount() to onDownloadSet.size
@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
fun downloadingIndices(): Set<Int> = onDownloadSet.toSet()
@Synchronized
fun isRun(): Boolean {
return waitDownloadSet.isNotEmpty() || onDownloadSet.isNotEmpty() || isLoading
return queue.waitingCount() > 0 || onDownloadSet.isNotEmpty() || isLoading
}
@Synchronized
@@ -309,7 +378,7 @@ object CacheBook {
@Synchronized
fun stop() {
waitDownloadSet.clear()
queue.clear()
pausedDownloadSet.clear()
chapterTasks.clear()
tasks.clear()
@@ -322,18 +391,39 @@ object CacheBook {
@Synchronized
fun addDownload(start: Int, end: Int) {
addDownloads(start..end)
addRequest(
CacheDownloadRequest(
book.bookUrl,
ChapterSelection.Range(start, end),
CacheDownloadSource.ReadPreload,
)
)
}
@Synchronized
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
for (i in indices) {
pausedDownloadSet.remove(i)
if (!onDownloadSet.contains(i)) {
waitDownloadSet.add(i)
when (val selection = request.selection) {
is ChapterSelection.Range -> {
pausedDownloadSet.removeAll { it in selection.start..selection.end }
}
is ChapterSelection.Indices -> selection.values.forEach { pausedDownloadSet.remove(it) }
is ChapterSelection.Single -> pausedDownloadSet.remove(selection.index)
}
queue.enqueue(request)
cacheBookMap[book.bookUrl] = this
isLoading = false
notifyDownloadSetChanged()
@@ -351,6 +441,7 @@ object CacheBook {
successDownloadSet.add(chapter.primaryStr())
errorDownloadMap.remove(chapter.primaryStr())
errorIndexMap[book.bookUrl]?.remove(chapter.index)
stateStore.markSuccess(book.bookUrl, chapter.index)
notifyDownloadSetChanged()
notifyErrorChanged()
_cacheSuccessFlow.tryEmit(chapter)
@@ -363,6 +454,7 @@ object CacheBook {
errorDownloadMap.merge(chapter.primaryStr(), 1) { old, inc -> old + inc }
errorIndexMap.getOrPut(book.bookUrl) { ConcurrentHashMap.newKeySet() }
.add(chapter.index)
stateStore.markFailed(book.bookUrl, chapter.index)
}
onDownloadSet.remove(chapter.index)
chapterTasks.remove(chapter.index)
@@ -372,7 +464,7 @@ object CacheBook {
private fun onPostError(chapter: BookChapter, error: Throwable) {
val retryCount = errorDownloadMap[chapter.primaryStr()] ?: 0
if (retryCount < 3 && !isStopped) {
waitDownloadSet.add(chapter.index)
queue.enqueue(ChapterSelection.Single(chapter.index))
} else {
AppLog.put("下载${book.name}-${chapter.title}失败\n${error.localizedMessage}", error)
}
@@ -391,14 +483,16 @@ object CacheBook {
private fun onCancel(index: Int) {
onDownloadSet.remove(index)
chapterTasks.remove(index)
if (!isStopped && !pausedDownloadSet.remove(index)) waitDownloadSet.add(index)
if (!isStopped && !pausedDownloadSet.remove(index)) {
queue.enqueue(ChapterSelection.Single(index))
}
notifyDownloadSetChanged()
}
@Synchronized
private fun onFinally() {
val bookUrl = book.bookUrl
if (waitDownloadSet.isEmpty() && onDownloadSet.isEmpty()) {
if (queue.waitingCount() == 0 && onDownloadSet.isEmpty()) {
CacheBook.onTaskRemoved(bookUrl)
} else {
CacheBook.onTaskQueuesChanged(bookUrl)
@@ -408,7 +502,7 @@ object CacheBook {
@Synchronized
fun removeDownload(index: Int): Boolean {
val removedWaiting = waitDownloadSet.remove(index)
val removedWaiting = queue.removeChapter(index)
val task = chapterTasks.remove(index)
val removedRunning = onDownloadSet.contains(index) || task != null
if (removedRunning) {
@@ -420,8 +514,8 @@ object CacheBook {
}
if (!removedWaiting && !removedRunning) return false
notifyDownloadSetChanged()
if (waitDownloadSet.isEmpty() && onDownloadSet.isEmpty()) {
CacheBook.onTaskRemoved(book.bookUrl)
if (queue.waitingCount() == 0 && onDownloadSet.isEmpty()) {
CacheBook.onTaskRemoved(book.bookUrl, clearState = true)
} else {
CacheBook.onTaskQueuesChanged(book.bookUrl)
}
@@ -433,42 +527,39 @@ object CacheBook {
*/
@Synchronized
fun download(scope: CoroutineScope, context: CoroutineContext) {
val chapterIndex = waitDownloadSet.firstOrNull()
if (chapterIndex == null) {
val candidate = queue.next(book.bookUrl, onDownloadSet)
if (candidate == null) {
if (!isLoading && onDownloadSet.isEmpty()) {
CacheBook.onTaskRemoved(book.bookUrl)
}
return
}
val chapterIndex = candidate.chapterIndex
if (onDownloadSet.contains(chapterIndex)) {
waitDownloadSet.remove(chapterIndex)
return
}
val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, chapterIndex) ?: run {
waitDownloadSet.remove(chapterIndex)
val chapter = repository.getChapter(book.bookUrl, chapterIndex) ?: run {
return
}
if (chapter.isVolume) {
postEvent(EventBus.SAVE_CONTENT, Pair(book, chapter))
_cacheSuccessFlow.tryEmit(chapter)
waitDownloadSet.remove(chapterIndex)
return
}
if (BookHelp.hasImageContent(book, chapter)) {
waitDownloadSet.remove(chapterIndex)
if (repository.hasImageContent(book, chapter)) {
return
}
waitDownloadSet.remove(chapterIndex)
onDownloadSet.add(chapterIndex)
notifyDownloadSetChanged()
if (BookHelp.hasContent(book, chapter)) {
val task = Coroutine.async(scope, context, executeContext = context) {
BookHelp.getContent(book, chapter)?.let {
BookHelp.saveImages(bookSource, book, chapter, it, 1)
}
}.onSuccess {
if (repository.hasContent(book, chapter)) {
val task = repository.saveCachedImagesTask(
scope = scope,
context = context,
bookSource = bookSource,
book = book,
chapter = chapter,
).onSuccess {
onSuccess(chapter)
}.onError {
onPreError(chapter, it)
@@ -485,14 +576,14 @@ object CacheBook {
return
}
val task = WebBook.getContent(
scope,
bookSource,
book,
chapter,
val task = repository.downloadContentTask(
scope = scope,
bookSource = bookSource,
book = book,
chapter = chapter,
context = context,
start = CoroutineStart.LAZY,
executeContext = context
executeContext = context,
).onSuccess { content ->
onSuccess(chapter)
downloadFinish(chapter, content)
@@ -515,11 +606,11 @@ object CacheBook {
suspend fun downloadAwait(chapter: BookChapter): String {
synchronized(this) {
onDownloadSet.add(chapter.index)
waitDownloadSet.remove(chapter.index)
queue.removeChapter(chapter.index)
notifyDownloadSetChanged()
}
try {
val content = WebBook.getContentAwait(bookSource, book, chapter)
val content = repository.downloadContentAwait(bookSource, book, chapter)
onSuccess(chapter)
ReadBook.downloadedChapters.add(chapter.index)
ReadBook.downloadFailChapters.remove(chapter.index)
@@ -544,15 +635,16 @@ object CacheBook {
) {
if (onDownloadSet.contains(chapter.index)) return
onDownloadSet.add(chapter.index)
waitDownloadSet.remove(chapter.index)
queue.removeChapter(chapter.index)
notifyDownloadSetChanged()
WebBook.getContent(
scope,
bookSource,
book,
chapter,
repository.downloadContentTask(
scope = scope,
bookSource = bookSource,
book = book,
chapter = chapter,
start = CoroutineStart.LAZY,
context = IO,
executeContext = IO,
semaphore = semaphore
).onSuccess { content ->
@@ -579,15 +671,15 @@ object CacheBook {
resetPageOffset: Boolean = false,
canceled: Boolean = false
) {
if (ReadBook.book?.bookUrl == book.bookUrl) {
ReadBook.contentLoadFinish(
ReadingCacheEvents.emit(
ReadingCacheEvent.ContentReady(
book = book,
chapter = chapter,
content = content,
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.globalExecutor
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.service.BaseReadAloudService
import io.legado.app.service.CacheBookService
@@ -88,6 +90,26 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
private val nextChapterLoadingLock = Mutex()
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
@@ -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.constant.AppConst
import io.legado.app.constant.AppLog
import io.legado.app.constant.EventBus
import io.legado.app.constant.IntentAction
import io.legado.app.constant.NotificationId
import io.legado.app.data.appDb
import io.legado.app.help.book.update
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.ui.config.otherConfig.OtherConfig
import io.legado.app.ui.main.MainActivity
import io.legado.app.utils.activityPendingIntent
import io.legado.app.utils.postEvent
import io.legado.app.utils.servicePendingIntent
import kotlinx.coroutines.Job
import kotlinx.coroutines.asCoroutineDispatcher
@@ -75,7 +76,6 @@ class CacheBookService : BaseService() {
delay(1000)
notificationContent = CacheBook.downloadSummary
upCacheBookNotification()
postEvent(EventBus.UP_DOWNLOAD, "")
}
}
}
@@ -111,18 +111,39 @@ class CacheBookService : BaseService() {
cachePool.close()
CacheBook.close()
super.onDestroy()
postEvent(EventBus.UP_DOWNLOAD, "")
}
private fun addDownloadData(bookUrl: String?, indices: List<Int>) {
private fun addDownloadData(bookUrl: String?, indices: Iterable<Int>) {
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 {
val cacheBook = CacheBook.getOrCreate(bookUrl) ?: return@execute
val cacheBook = CacheBook.getOrCreate(request.bookUrl) ?: return@execute
val book = cacheBook.book
val chapterCount = appDb.bookChapterDao.getChapterCount(bookUrl)
val chapterCount = appDb.bookChapterDao.getChapterCount(request.bookUrl)
if (chapterCount == 0) {
cacheBook.setLoading()
@@ -132,7 +153,7 @@ class CacheBookService : BaseService() {
kotlin.runCatching {
WebBook.getBookInfoAwait(cacheBook.bookSource, book)
}.onFailure {
removeDownload(bookUrl)
removeDownload(request.bookUrl)
AppLog.put(
"$name》目录为空且加载详情页失败\n${it.localizedMessage}",
it,
@@ -147,7 +168,7 @@ class CacheBookService : BaseService() {
book.totalChapterNum = 0
book.update()
}
removeDownload(bookUrl)
removeDownload(request.bookUrl)
AppLog.put(
"$name》目录为空且加载目录失败\n${it.localizedMessage}",
it,
@@ -163,7 +184,7 @@ class CacheBookService : BaseService() {
}
//添加章节到下载队列
cacheBook.addDownloads(indices)
cacheBook.addRequest(request)
notificationContent = CacheBook.downloadSummary
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?) {
CacheBook.cacheBookMap[bookUrl]?.stop()
postEvent(EventBus.UP_DOWNLOAD, "")
if (downloadJob == null && CacheBook.isRun) {
download()
return
@@ -19,6 +19,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.isActive
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
@@ -26,6 +28,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.min
data class BookCacheManageUiState(
@@ -94,6 +97,11 @@ class BookCacheManageViewModel(
private val clearBookCacheUseCase: ClearBookCacheUseCase,
) : 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())
val uiState = _uiState.asStateFlow()
@@ -103,6 +111,9 @@ class BookCacheManageViewModel(
private var observeJob: Job? = null
private var fullReloadJob: Job? = null
private val bookReloadJobs = hashMapOf<String, Job>()
private val pendingDownloadRefreshBookUrls = ConcurrentHashMap.newKeySet<String>()
@Volatile
private var pendingDownloadSummaryRefresh = false
fun onIntent(intent: BookCacheManageIntent) {
when (intent) {
@@ -140,32 +151,24 @@ class BookCacheManageViewModel(
}
viewModelScope.launch {
CacheBook.cacheSuccessFlow.collect { chapter ->
scheduleBookReload(chapter.bookUrl)
scheduleDownloadStatusRefresh(chapter.bookUrl)
}
}
viewModelScope.launch {
CacheBook.downloadingIndicesFlow.collect { state ->
scheduleBookReload(state.first)
CacheBook.downloadStateFlow.collect { state ->
state.books.keys.forEach { scheduleDownloadStatusRefresh(it) }
pendingDownloadSummaryRefresh = true
}
}
viewModelScope.launch {
CacheBook.queueChangedFlow.collect { bookUrl ->
scheduleBookReload(bookUrl)
scheduleDownloadStatusRefresh(bookUrl)
}
}
viewModelScope.launch {
CacheBook.downloadErrorFlow.collect { state ->
scheduleBookReload(state.first)
}
}
viewModelScope.launch {
CacheBook.downloadSummaryFlow.collect {
_uiState.update {
it.copy(
downloadSummary = buildDownloadSummary(it.shelfBooks + it.notShelfBooks),
version = it.version + 1,
)
}
while (isActive) {
delay(DOWNLOAD_STATUS_REFRESH_INTERVAL_MILLIS)
flushDownloadStatusRefresh()
}
}
}
@@ -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) {
if (bookUrl.isBlank()) return
bookReloadJobs.remove(bookUrl)?.cancel()
@@ -278,8 +306,7 @@ class BookCacheManageViewModel(
private fun buildBookItem(book: Book): BookCacheBookItem? {
val cacheFiles = BookHelp.getChapterFiles(book)
val model = CacheBook.cacheBookMap[book.bookUrl]
val waitingIndices = model?.waitingIndices().orEmpty()
val downloadingIndices = model?.downloadingIndices().orEmpty()
val (waitingCount, downloadingCount) = model?.queueCounts() ?: (0 to 0)
val errorIndices = errorIndices(book.bookUrl)
val totalCount = bookChapterDao.getChapterCount(book.bookUrl)
val cachedFileCount = cacheFiles.count { it.endsWith(".nb") }
@@ -293,8 +320,8 @@ class BookCacheManageViewModel(
author = book.getRealAuthor(),
totalCount = totalCount,
cachedCount = cachedCount,
waitingCount = waitingIndices.size,
downloadingCount = downloadingIndices.size,
waitingCount = waitingCount,
downloadingCount = downloadingCount,
errorCount = errorIndices.size,
isNotShelf = book.isNotShelf,
)
@@ -305,8 +332,6 @@ class BookCacheManageViewModel(
val chapters = bookChapterDao.getChapterCacheInfoList(bookUrl)
val cacheFiles = BookHelp.getChapterFiles(book)
val model = CacheBook.cacheBookMap[bookUrl]
val waitingIndices = model?.waitingIndices().orEmpty()
val downloadingIndices = model?.downloadingIndices().orEmpty()
val errorIndices = errorIndices(bookUrl)
return chapters.map { chapter ->
BookCacheChapterItem(
@@ -314,8 +339,8 @@ class BookCacheManageViewModel(
title = chapter.title,
index = chapter.index,
isCached = cacheFiles.contains(chapter.getFileName()) || chapter.isVolume,
isWaiting = waitingIndices.contains(chapter.index),
isDownloading = downloadingIndices.contains(chapter.index),
isWaiting = model?.isWaiting(chapter.index) == true,
isDownloading = model?.isDownloading(chapter.index) == true,
isError = errorIndices.contains(chapter.index),
)
}
@@ -377,10 +402,14 @@ class BookCacheManageViewModel(
private fun startAllDownloads() {
val items = uiState.value.shelfBooks + uiState.value.notShelfBooks
execute {
items.sumOf { item ->
val chapterIndices = downloadableChapterIndices(item.bookUrl)
cacheBookChaptersUseCase.execute(item.bookUrl, chapterIndices)
var count = 0
items.forEach { item ->
downloadableChapterIndexBatches(item.bookUrl).forEach { chapterIndices ->
count += cacheBookChaptersUseCase.execute(item.bookUrl, chapterIndices)
}
currentCoroutineContext().ensureActive()
}
count
}.onSuccess { count ->
if (count > 0) {
_effects.tryEmit(BookCacheManageEffect.ShowMessage("已加入缓存队列: $count"))
@@ -396,7 +425,11 @@ class BookCacheManageViewModel(
private fun startBookDownload(bookUrl: String) {
execute {
cacheBookChaptersUseCase.execute(bookUrl, downloadableChapterIndices(bookUrl))
var count = 0
downloadableChapterIndexBatches(bookUrl).forEach { chapterIndices ->
count += cacheBookChaptersUseCase.execute(bookUrl, chapterIndices)
}
count
}.onSuccess { count ->
if (count > 0) {
_effects.tryEmit(BookCacheManageEffect.ShowMessage("已加入缓存队列: $count"))
@@ -410,22 +443,32 @@ class BookCacheManageViewModel(
}
}
private fun downloadableChapterIndices(bookUrl: String): List<Int> {
val book = bookDao.getBook(bookUrl) ?: return emptyList()
private fun downloadableChapterIndexBatches(
bookUrl: String,
batchSize: Int = DOWNLOAD_BATCH_SIZE,
): Sequence<List<Int>> = sequence {
val book = bookDao.getBook(bookUrl) ?: return@sequence
val cacheFiles = BookHelp.getChapterFiles(book)
val model = CacheBook.cacheBookMap[bookUrl]
val waitingIndices = model?.waitingIndices().orEmpty()
val downloadingIndices = model?.downloadingIndices().orEmpty()
return bookChapterDao.getChapterCacheInfoList(bookUrl)
.asSequence()
.filterNot { chapter ->
var batch = ArrayList<Int>(batchSize)
for (chapter in bookChapterDao.getChapterCacheInfoList(bookUrl)) {
if (
chapter.isVolume ||
cacheFiles.contains(chapter.getFileName()) ||
waitingIndices.contains(chapter.index) ||
downloadingIndices.contains(chapter.index)
model?.isWaiting(chapter.index) == true ||
model?.isDownloading(chapter.index) == true
) {
continue
}
.map { it.index }
.toList()
batch.add(chapter.index)
if (batch.size == batchSize) {
yield(batch)
batch = ArrayList(batchSize)
}
}
if (batch.isNotEmpty()) {
yield(batch)
}
}
private fun deleteBookCache(bookUrl: String) {
@@ -37,7 +37,9 @@ import io.legado.app.utils.cnCompare
import io.legado.app.utils.move
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.isActive
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
@@ -155,6 +157,10 @@ class BookshelfManageScreenViewModel(
private val updateBooksGroupUseCase: UpdateBooksGroupUseCase
) : BaseViewModel(application) {
private companion object {
const val DOWNLOAD_STATUS_REFRESH_INTERVAL_MILLIS = 2_000L
}
private val _uiState = MutableStateFlow(BookshelfManageScreenUiState())
val uiState = _uiState.asStateFlow()
@@ -165,8 +171,11 @@ class BookshelfManageScreenViewModel(
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
private val pendingDownloadStatusBookUrls = ConcurrentHashMap.newKeySet<String>()
private val pendingCacheCountRefreshBookUrls = ConcurrentHashMap.newKeySet<String>()
@Volatile
private var pendingDownloadRunningRefresh = false
fun dispatch(intent: BookshelfManageScreenIntent) {
when (intent) {
@@ -367,28 +376,26 @@ class BookshelfManageScreenViewModel(
observersStarted = true
viewModelScope.launch {
CacheBook.cacheSuccessFlow.collect { chapter ->
onChapterCached(chapter)
scheduleCacheCountRefresh(chapter.bookUrl)
}
}
viewModelScope.launch {
CacheBook.downloadingIndicesFlow.collect { (bookUrl, _) ->
syncDownloadRunning()
if (bookUrl.isNotEmpty()) {
emitBookChanged(bookUrl)
CacheBook.downloadStateFlow.collect { state ->
state.books.keys.forEach { bookUrl ->
scheduleDownloadStatusRefresh(bookUrl)
}
scheduleDownloadStatusRefresh()
}
}
viewModelScope.launch {
CacheBook.downloadErrorFlow.collect { (bookUrl, _) ->
syncDownloadRunning()
if (bookUrl.isNotEmpty()) {
emitBookChanged(bookUrl)
}
CacheBook.queueChangedFlow.collect { bookUrl ->
scheduleDownloadStatusRefresh(bookUrl)
}
}
viewModelScope.launch {
CacheBook.downloadSummaryFlow.collect {
syncDownloadRunning()
viewModelScope.launch(Dispatchers.IO) {
while (isActive) {
delay(DOWNLOAD_STATUS_REFRESH_INTERVAL_MILLIS)
flushDownloadStatusRefresh()
}
}
viewModelScope.launch {
@@ -432,8 +439,6 @@ class BookshelfManageScreenViewModel(
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 ->
@@ -451,22 +456,48 @@ class BookshelfManageScreenViewModel(
}
}
private fun onChapterCached(chapter: BookChapter) {
val bookUrl = chapter.bookUrl
scheduleCacheCountRefresh(bookUrl)
private fun scheduleCacheCountRefresh(bookUrl: String) {
if (bookUrl.isNotBlank()) {
pendingCacheCountRefreshBookUrls.add(bookUrl)
}
pendingDownloadRunningRefresh = true
}
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 scheduleDownloadStatusRefresh(bookUrl: String = "") {
if (bookUrl.isNotBlank()) {
pendingDownloadStatusBookUrls.add(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 {
@@ -496,7 +527,7 @@ class BookshelfManageScreenViewModel(
syncDownloadRunning()
} else {
execute {
cacheBookChaptersUseCase.execute(book.bookUrl, 0..book.lastChapterIndex)
cacheBookChaptersUseCase.executeRange(book.bookUrl, 0, book.lastChapterIndex)
}.onFinally {
syncDownloadRunning()
}
@@ -873,4 +904,12 @@ class BookshelfManageScreenViewModel(
_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 {
if (it.isEmpty()) book.totalChapterNum else it.toInt()
}
val indices = (start - 1..end - 1).toList()
CacheBook.start(this@BaseReadBookActivity, book, indices)
CacheBook.start(this@BaseReadBookActivity, book, start - 1, end - 1)
}
}
cancelButton()
@@ -21,6 +21,7 @@ import io.legado.app.help.bookmark.BookmarkExporter
import io.legado.app.help.config.AppConfig
import io.legado.app.model.CacheBook
import io.legado.app.model.ReadBook
import io.legado.app.model.cache.CacheBookDownloadState
import io.legado.app.model.localBook.LocalBook
import io.legado.app.ui.config.readConfig.ReadConfig
import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
@@ -89,8 +90,7 @@ data class TocDomainItem(
)
private data class DownloadContext(
val downloadingPair: Pair<String, Set<Int>>,
val errorPair: Pair<String, Set<Int>>,
val downloadState: CacheBookDownloadState?,
val cachedFiles: Set<String>
)
@@ -206,11 +206,11 @@ class TocViewModel(
.distinctUntilChanged()
private val downloadContextFlow = combine(
CacheBook.downloadingIndicesFlow,
CacheBook.downloadErrorFlow,
bookState.filterNotNull().map { it.bookUrl }.distinctUntilChanged(),
CacheBook.downloadStateFlow,
_cacheFileNames
) { downloading, errors, cached ->
DownloadContext(downloading, errors, cached)
) { bookUrl, state, cached ->
DownloadContext(state.books[bookUrl], cached)
}
private val uiConfigFlow = combine(
@@ -263,15 +263,13 @@ class TocViewModel(
}
}
val (downloadingPair, errorPair, cachedFiles) = downloadCtx
val downloadingIndices =
if (downloadingPair.first == book.bookUrl) downloadingPair.second else emptySet()
val errorIndices =
if (errorPair.first == book.bookUrl) errorPair.second else emptySet()
val runningIndices = downloadCtx.downloadState?.runningIndices.orEmpty()
val errorIndices = downloadCtx.downloadState?.failedIndices.orEmpty()
val cachedFiles = downloadCtx.cachedFiles
processedChapters.map { chapter ->
val downloadState = when {
chapter.index in downloadingIndices -> DownloadState.DOWNLOADING
chapter.index in runningIndices -> DownloadState.DOWNLOADING
chapter.index in errorIndices -> DownloadState.ERROR
chapter.getFileName() in cachedFiles -> DownloadState.SUCCESS
else -> DownloadState.NONE