feat(cache): 添加漫画图片缓存功能及图片缓存进度跟踪功能 (#1610)

* feat(cache): 添加图片缓存进度跟踪功能

- 在 BookHelp 中新增 countImagesInContent 函数用于统计章节中的图片数量
- 修改 saveImages 函数支持进度回调,实现图片下载进度追踪
- 新增 countImageCachedChapters 函数用于统计已缓存的图片章节
- 在 OkHttpStreamFetcher 中实现本地图片加载逻辑,支持从缓存读取图片
- 添加 CacheChapterProgress 相关数据类用于跟踪章节下载进度
- 在 CacheDownloadStateStore 中实现章节进度更新和清除功能
- 修改 CacheBookModel 支持内容和图片分阶段下载,并报告进度
- 更新 ReadManga 中的图片缓存逻辑,在内容下载后自动缓存图片
- 在 BookCacheManageScreen 和 ViewModel 中添加进度指示器显示下载状态
- 优化缓存章节计数逻辑,区分图片书籍和其他类型书籍的缓存判断

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cache): 修复 PR #1610 Code Review 指出的问题

- 图片缓存完成后保留正文内容,避免阅读器无限加载
- 将 ReadManga 中的磁盘 I/O 移至 IO 线程
- 使用 Mutex 保证图片下载进度回调顺序递增

* fix(cache): 修复图片缓存任务卡住与 UI 刷新性能问题

- startImageCacheTask 提前返回时清理 onDownloadSet 并同步队列状态
- 新增 hasImageFilesCached 轻量检查,进度更新时避免全量文件扫描

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
AugusChen
2026-07-01 01:02:07 +08:00
committed by GitHub
co-authored by Cursor
parent 3b65bd66b2
commit 44b1b582c0
10 changed files with 480 additions and 23 deletions
@@ -37,10 +37,13 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.apache.commons.text.similarity.JaccardSimilarity import org.apache.commons.text.similarity.JaccardSimilarity
import splitties.init.appCtx import splitties.init.appCtx
@@ -285,15 +288,35 @@ object BookHelp {
} }
} }
fun countImagesInContent(bookChapter: BookChapter, content: String): Int {
var count = 0
val matcher = AppPattern.imgPattern.matcher(content)
while (matcher.find()) {
if (matcher.group(1) != null) count++
}
return count
}
suspend fun saveImages( suspend fun saveImages(
bookSource: BookSource, bookSource: BookSource,
book: Book, book: Book,
bookChapter: BookChapter, bookChapter: BookChapter,
content: String, content: String,
concurrency: Int = OtherConfig.threadCount concurrency: Int = OtherConfig.threadCount,
onProgress: (suspend (completed: Int, total: Int) -> Unit)? = null,
) = coroutineScope { ) = coroutineScope {
flowImages(bookChapter, content).onEachParallel(concurrency) { mSrc -> val imageUrls = flowImages(bookChapter, content).toList()
val total = imageUrls.size
onProgress?.invoke(0, total)
if (total == 0) return@coroutineScope
val progressMutex = Mutex()
var completed = 0
imageUrls.asFlow().onEachParallel(concurrency) { mSrc ->
saveImage(bookSource, book, mSrc, bookChapter) saveImage(bookSource, book, mSrc, bookChapter)
progressMutex.withLock {
completed++
onProgress?.invoke(completed, total)
}
}.collect() }.collect()
} }
@@ -464,6 +487,13 @@ object BookHelp {
/** /**
* 检测该章节是否下载 * 检测该章节是否下载
*/ */
fun countImageCachedChapters(book: Book): Int {
if (!book.isImage) return 0
return appDb.bookChapterDao.getChapterList(book.bookUrl).count { chapter ->
chapter.isVolume || hasImageFilesCached(book, chapter)
}
}
fun hasContent(book: Book, bookChapter: BookChapter): Boolean { fun hasContent(book: Book, bookChapter: BookChapter): Boolean {
return if (book.isLocalTxt || return if (book.isLocalTxt ||
(bookChapter.isVolume && bookChapter.url.startsWith(bookChapter.title)) (bookChapter.isVolume && bookChapter.url.startsWith(bookChapter.title))
@@ -479,7 +509,24 @@ object BookHelp {
} }
/** /**
* 检测图片是否下载 * UI/队列用:仅检查图片文件是否都存在,不做 bitmap 解码校验。
*/
fun hasImageFilesCached(book: Book, bookChapter: BookChapter): Boolean {
if (!hasContent(book, bookChapter)) {
return false
}
var ret = true
forEachImageSrc(book, bookChapter) { src ->
if (!isImageExist(book, src)) {
ret = false
return@forEachImageSrc
}
}
return ret
}
/**
* 检测图片是否下载(含 bitmap 解码校验,用于实际下载决策)
*/ */
fun hasImageContent(book: Book, bookChapter: BookChapter): Boolean { fun hasImageContent(book: Book, bookChapter: BookChapter): Boolean {
if (!hasContent(book, bookChapter)) { if (!hasContent(book, bookChapter)) {
@@ -9,7 +9,9 @@ import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.util.ContentLengthInputStream import com.bumptech.glide.util.ContentLengthInputStream
import com.script.rhino.runScriptWithContext import com.script.rhino.runScriptWithContext
import io.legado.app.data.entities.BaseSource import io.legado.app.data.entities.BaseSource
import io.legado.app.data.entities.Book
import io.legado.app.exception.NoStackTraceException import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.book.BookHelp
import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.coroutine.Coroutine
import io.legado.app.help.http.addHeaders import io.legado.app.help.http.addHeaders
import io.legado.app.help.http.okHttpClient import io.legado.app.help.http.okHttpClient
@@ -45,6 +47,7 @@ class OkHttpStreamFetcher(
private val coroutineContext = SupervisorJob() private val coroutineContext = SupervisorJob()
private val coroutineScope = CoroutineScope(coroutineContext) private val coroutineScope = CoroutineScope(coroutineContext)
private lateinit var analyzedUrl: GlideUrl private lateinit var analyzedUrl: GlideUrl
private var dataSource = DataSource.REMOTE
@Volatile @Volatile
private var call: Call? = null private var call: Call? = null
@@ -68,6 +71,20 @@ class OkHttpStreamFetcher(
source = SourceHelp.getSource(sourceUrl) source = SourceHelp.getSource(sourceUrl)
} }
this.callback = callback
if (manga) {
ReadManga.book?.let { book ->
val src = url.toString()
if (BookHelp.isImageExist(book, src)) {
Coroutine.async(coroutineScope, executeContext = IO) {
loadLocalImage(book, src)
}
return
}
}
}
analyzedUrl = AnalyzeUrl( analyzedUrl = AnalyzeUrl(
url.toString(), url.toString(),
source = source, source = source,
@@ -77,7 +94,7 @@ class OkHttpStreamFetcher(
val requestBuilder = Request.Builder().url(analyzedUrl.toStringUrl()) val requestBuilder = Request.Builder().url(analyzedUrl.toStringUrl())
requestBuilder.addHeaders(analyzedUrl.headers) requestBuilder.addHeaders(analyzedUrl.headers)
val request: Request = requestBuilder.build() val request: Request = requestBuilder.build()
this.callback = callback dataSource = DataSource.REMOTE
call = if (manga) { call = if (manga) {
okHttpClientManga.newCall(request) okHttpClientManga.newCall(request)
} else { } else {
@@ -86,6 +103,29 @@ class OkHttpStreamFetcher(
call?.enqueue(this) call?.enqueue(this)
} }
private suspend fun loadLocalImage(book: Book, src: String) {
try {
val bytes = BookHelp.getImage(book, src).readBytes()
val decodeResult = if (ImageUtils.skipDecode(source, isCover = false)) {
ByteArrayInputStream(bytes)
} else {
runScriptWithContext(coroutineContext) {
ImageUtils.decode(
src,
bytes,
isCover = false,
source,
book
)?.inputStream()
}
}
dataSource = DataSource.LOCAL
onStreamReady(decodeResult)
} catch (e: Exception) {
callback?.onLoadFailed(e)
}
}
override fun cleanup() { override fun cleanup() {
kotlin.runCatching { kotlin.runCatching {
stream?.close() stream?.close()
@@ -105,7 +145,7 @@ class OkHttpStreamFetcher(
} }
override fun getDataSource(): DataSource { override fun getDataSource(): DataSource {
return DataSource.REMOTE return dataSource
} }
override fun onFailure(call: Call, e: IOException) { override fun onFailure(call: Call, e: IOException) {
@@ -4,7 +4,9 @@ import io.legado.app.constant.AppLog
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.help.book.isImage
import io.legado.app.exception.ConcurrentException import io.legado.app.exception.ConcurrentException
import io.legado.app.help.book.BookHelp
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.cache.CacheDownloadCandidate import io.legado.app.model.cache.CacheDownloadCandidate
@@ -12,6 +14,8 @@ import io.legado.app.model.cache.CacheDownloadQueue
import io.legado.app.model.cache.CacheDownloadRepository import io.legado.app.model.cache.CacheDownloadRepository
import io.legado.app.model.cache.CacheDownloadRequest import io.legado.app.model.cache.CacheDownloadRequest
import io.legado.app.model.cache.CacheDownloadSource import io.legado.app.model.cache.CacheDownloadSource
import io.legado.app.model.cache.CacheChapterProgress
import io.legado.app.model.cache.CacheChapterProgressPhase
import io.legado.app.model.cache.CacheDownloadStateStore import io.legado.app.model.cache.CacheDownloadStateStore
import io.legado.app.model.cache.ChapterSelection import io.legado.app.model.cache.ChapterSelection
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@@ -423,6 +427,7 @@ class CacheBookModel(
} }
if (repository.hasContent(book, chapter)) { if (repository.hasContent(book, chapter)) {
reportImageDownloadProgress(chapter, completed = 0)
val task = repository.saveCachedImagesTask( val task = repository.saveCachedImagesTask(
scope = scope, scope = scope,
context = context, context = context,
@@ -430,8 +435,11 @@ class CacheBookModel(
book = book, book = book,
chapter = chapter, chapter = chapter,
start = CoroutineStart.LAZY, start = CoroutineStart.LAZY,
onProgress = { completed, total ->
reportImageDownloadProgress(chapter, completed, total)
},
) )
if (!attachTaskIfActive(task, chapter, chapterIndex)) { if (!attachTaskIfActive(task, chapter, chapterIndex, scope, context)) {
task.cancel() task.cancel()
return return
} }
@@ -439,6 +447,7 @@ class CacheBookModel(
return return
} }
reportContentDownloadProgress(chapterIndex)
val task = repository.cacheContentTask( val task = repository.cacheContentTask(
scope = scope, scope = scope,
bookSource = bookSource, bookSource = bookSource,
@@ -448,7 +457,7 @@ class CacheBookModel(
start = CoroutineStart.LAZY, start = CoroutineStart.LAZY,
executeContext = context, executeContext = context,
) )
if (!attachTaskIfActive(task, chapter, chapterIndex)) { if (!attachTaskIfActive(task, chapter, chapterIndex, scope, context, chainImagesAfterContent = true)) {
task.cancel() task.cancel()
return return
} }
@@ -494,6 +503,9 @@ class CacheBookModel(
task: Coroutine<T>, task: Coroutine<T>,
chapter: BookChapter, chapter: BookChapter,
chapterIndex: Int, chapterIndex: Int,
scope: CoroutineScope,
context: CoroutineContext,
chainImagesAfterContent: Boolean = false,
): Boolean { ): Boolean {
if (isStopped || isPaused || !onDownloadSet.contains(chapterIndex)) { if (isStopped || isPaused || !onDownloadSet.contains(chapterIndex)) {
if (!isStopped && isPaused && onDownloadSet.remove(chapterIndex)) { if (!isStopped && isPaused && onDownloadSet.remove(chapterIndex)) {
@@ -503,7 +515,7 @@ class CacheBookModel(
} }
return false return false
} }
attachCallbacks(task, chapter, chapterIndex) attachCallbacks(task, chapter, chapterIndex, scope, context, chainImagesAfterContent)
chapterTasks[chapterIndex] = task chapterTasks[chapterIndex] = task
tasks.add(task) tasks.add(task)
return true return true
@@ -513,12 +525,17 @@ class CacheBookModel(
task: Coroutine<T>, task: Coroutine<T>,
chapter: BookChapter, chapter: BookChapter,
chapterIndex: Int, chapterIndex: Int,
scope: CoroutineScope,
context: CoroutineContext,
chainImagesAfterContent: Boolean = false,
content: String? = null,
) { ) {
task.onSuccess(IO) { task.onSuccess(IO) {
onSuccess(chapter) if (chainImagesAfterContent && book.isImage && !repository.hasImageContent(book, chapter)) {
(it as? String)?.let { content -> startImageCacheTask(scope, context, chapter, chapterIndex, it as String)
emitPendingReadContent(chapter, content) return@onSuccess
} }
completeChapterCache(chapter, content ?: (it as? String))
}.onError(IO) { }.onError(IO) {
onPreError(chapter, it) onPreError(chapter, it)
try { try {
@@ -531,11 +548,89 @@ class CacheBookModel(
onCancel(chapterIndex) onCancel(chapterIndex)
emitPendingReadCanceled(chapter) emitPendingReadCanceled(chapter)
}.onFinally(IO) { }.onFinally(IO) {
chapterTasks.remove(chapterIndex)?.let { tasks.delete(it) } if (chapterTasks[chapterIndex] === task) {
chapterTasks.remove(chapterIndex)
tasks.delete(task)
}
onFinally() onFinally()
} }
} }
private fun completeChapterCache(chapter: BookChapter, content: String?) {
onSuccess(chapter)
content?.let { emitPendingReadContent(chapter, it) }
}
@Synchronized
private fun startImageCacheTask(
scope: CoroutineScope,
context: CoroutineContext,
chapter: BookChapter,
chapterIndex: Int,
content: String,
) {
if (isStopped || isPaused || !onDownloadSet.contains(chapterIndex)) {
if (!isStopped && isPaused && onDownloadSet.remove(chapterIndex)) {
queue.enqueue(ChapterSelection.Single(chapterIndex))
} else {
onDownloadSet.remove(chapterIndex)
}
host.stateStore.clearChapterProgress(book.bookUrl, chapterIndex)
notifyDownloadSetChanged()
host.onTaskQueuesChanged(book.bookUrl)
return
}
reportImageDownloadProgress(chapter, completed = 0)
val imageTask = repository.saveCachedImagesTask(
scope = scope,
context = context,
bookSource = bookSource,
book = book,
chapter = chapter,
start = CoroutineStart.LAZY,
onProgress = { completed, total ->
reportImageDownloadProgress(chapter, completed, total)
},
)
attachCallbacks(imageTask, chapter, chapterIndex, scope, context, content = content)
chapterTasks[chapterIndex] = imageTask
tasks.add(imageTask)
imageTask.start()
}
private fun reportContentDownloadProgress(chapterIndex: Int) {
host.stateStore.updateChapterProgress(
book.bookUrl,
chapterIndex,
CacheChapterProgress(
phase = CacheChapterProgressPhase.CONTENT,
completed = 0,
total = 1,
),
)
}
private fun reportImageDownloadProgress(
chapter: BookChapter,
completed: Int,
total: Int = imageCountInChapter(chapter),
) {
host.stateStore.updateChapterProgress(
book.bookUrl,
chapter.index,
CacheChapterProgress(
phase = CacheChapterProgressPhase.IMAGES,
completed = completed,
total = total,
),
)
}
private fun imageCountInChapter(chapter: BookChapter): Int {
val content = BookHelp.getContent(book, chapter) ?: return 0
return BookHelp.countImagesInContent(chapter, content)
}
suspend fun downloadAwait(chapter: BookChapter): String { suspend fun downloadAwait(chapter: BookChapter): String {
synchronized(this) { synchronized(this) {
onDownloadSet.add(chapter.index) onDownloadSet.add(chapter.index)
@@ -544,6 +639,16 @@ class CacheBookModel(
} }
try { try {
val content = repository.downloadContentAwait(bookSource, book, chapter) val content = repository.downloadContentAwait(bookSource, book, chapter)
if (book.isImage && !repository.hasImageContent(book, chapter)) {
repository.saveCachedImagesAwait(
bookSource = bookSource,
book = book,
chapter = chapter,
onProgress = { completed, total ->
reportImageDownloadProgress(chapter, completed, total)
},
)
}
onSuccess(chapter) onSuccess(chapter)
ReadBook.downloadedChapters.add(chapter.index) ReadBook.downloadedChapters.add(chapter.index)
ReadBook.downloadFailChapters.remove(chapter.index) ReadBook.downloadFailChapters.remove(chapter.index)
@@ -599,6 +704,11 @@ class CacheBookModel(
executeContext = IO, executeContext = IO,
semaphore = semaphore semaphore = semaphore
).timeout(DOWNLOAD_TIMEOUT_MS).onSuccess { content -> ).timeout(DOWNLOAD_TIMEOUT_MS).onSuccess { content ->
if (book.isImage && !repository.hasImageContent(book, chapter)) {
Coroutine.async(scope, IO) {
repository.saveCachedImagesAwait(bookSource, book, chapter)
}.start()
}
onSuccess(chapter) onSuccess(chapter)
ReadBook.downloadedChapters.add(chapter.index) ReadBook.downloadedChapters.add(chapter.index)
ReadBook.downloadFailChapters.remove(chapter.index) ReadBook.downloadFailChapters.remove(chapter.index)
@@ -13,6 +13,7 @@ import io.legado.app.help.AppWebDav
import io.legado.app.help.ConcurrentRateLimiter import io.legado.app.help.ConcurrentRateLimiter
import io.legado.app.help.book.BookHelp import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.ContentProcessor import io.legado.app.help.book.ContentProcessor
import io.legado.app.help.book.isImage
import io.legado.app.help.book.isLocal import io.legado.app.help.book.isLocal
import io.legado.app.help.book.isSameNameAuthor import io.legado.app.help.book.isSameNameAuthor
import io.legado.app.help.book.readSimulating import io.legado.app.help.book.readSimulating
@@ -468,6 +469,20 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
}.start() }.start()
} }
private fun cacheChapterImagesIfNeeded(
book: Book,
chapter: BookChapter,
content: String? = null,
) {
if (!book.isImage) return
val source = bookSource ?: return
Coroutine.async(downloadScope, IO) {
if (BookHelp.hasImageContent(book, chapter)) return@async
val resolvedContent = content ?: BookHelp.getContent(book, chapter) ?: return@async
BookHelp.saveImages(source, book, chapter, resolvedContent)
}.start()
}
private fun preDownload() { private fun preDownload() {
if (book?.isLocal == true) return if (book?.isLocal == true) return
executor.execute { executor.execute {
@@ -515,6 +530,7 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, index) ?: return val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, index) ?: return
if (BookHelp.hasContent(book, chapter)) { if (BookHelp.hasContent(book, chapter)) {
downloadedChapters.add(chapter.index) downloadedChapters.add(chapter.index)
cacheChapterImagesIfNeeded(book, chapter)
} else { } else {
delay(1000) delay(1000)
if (addLoading(index)) { if (addLoading(index)) {
@@ -534,10 +550,11 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
val book = book ?: return removeLoading(chapter.index) val book = book ?: return removeLoading(chapter.index)
val bookSource = bookSource val bookSource = bookSource
if (bookSource != null) { if (bookSource != null) {
downloadNetworkContent(bookSource, scope, chapter, book, semaphore, success = { downloadNetworkContent(bookSource, scope, chapter, book, semaphore, success = { content ->
cacheChapterImagesIfNeeded(book, chapter, content)
downloadedChapters.add(chapter.index) downloadedChapters.add(chapter.index)
downloadFailChapters.remove(chapter.index) downloadFailChapters.remove(chapter.index)
contentLoadFinish(chapter, it) contentLoadFinish(chapter, content)
}, error = { }, error = {
downloadFailChapters[chapter.index] = downloadFailChapters[chapter.index] =
(downloadFailChapters[chapter.index] ?: 0) + 1 (downloadFailChapters[chapter.index] ?: 0) + 1
@@ -45,4 +45,19 @@ data class CacheBookDownloadState(
val failedIndices: Set<Int> = emptySet(), val failedIndices: Set<Int> = emptySet(),
val successCount: Int = 0, val successCount: Int = 0,
val failureMessage: String? = null, val failureMessage: String? = null,
val chapterProgress: Map<Int, CacheChapterProgress> = emptyMap(),
) )
enum class CacheChapterProgressPhase {
CONTENT,
IMAGES,
}
data class CacheChapterProgress(
val phase: CacheChapterProgressPhase,
val completed: Int,
val total: Int,
) {
val fraction: Float
get() = if (total <= 0) 0f else completed.toFloat() / total
}
@@ -33,11 +33,21 @@ class CacheDownloadRepository {
book: Book, book: Book,
chapter: BookChapter, chapter: BookChapter,
start: CoroutineStart = CoroutineStart.LAZY, start: CoroutineStart = CoroutineStart.LAZY,
onProgress: (suspend (completed: Int, total: Int) -> Unit)? = null,
): Coroutine<Unit> { ): Coroutine<Unit> {
return Coroutine.async(scope, context, start = start, executeContext = context) { return Coroutine.async(scope, context, start = start, executeContext = context) {
BookHelp.getContent(book, chapter)?.let { saveCachedImagesAwait(bookSource, book, chapter, onProgress)
BookHelp.saveImages(bookSource, book, chapter, it, 1) }
} }
suspend fun saveCachedImagesAwait(
bookSource: BookSource,
book: Book,
chapter: BookChapter,
onProgress: (suspend (completed: Int, total: Int) -> Unit)? = null,
) {
BookHelp.getContent(book, chapter)?.let { content ->
BookHelp.saveImages(bookSource, book, chapter, content, 1, onProgress)
} }
} }
@@ -40,6 +40,7 @@ class CacheDownloadStateStore {
failedIndices = current.failedIndices - chapterIndex, failedIndices = current.failedIndices - chapterIndex,
successCount = current.successCount + 1, successCount = current.successCount + 1,
failureMessage = null, failureMessage = null,
chapterProgress = current.chapterProgress - chapterIndex,
) )
} }
} }
@@ -50,10 +51,27 @@ class CacheDownloadStateStore {
runningIndices = current.runningIndices - chapterIndex, runningIndices = current.runningIndices - chapterIndex,
pausedIndices = current.pausedIndices - chapterIndex, pausedIndices = current.pausedIndices - chapterIndex,
failedIndices = current.failedIndices + chapterIndex, failedIndices = current.failedIndices + chapterIndex,
chapterProgress = current.chapterProgress - chapterIndex,
) )
} }
} }
fun updateChapterProgress(
bookUrl: String,
chapterIndex: Int,
progress: CacheChapterProgress,
) {
updateBook(bookUrl) { current ->
current.copy(chapterProgress = current.chapterProgress + (chapterIndex to progress))
}
}
fun clearChapterProgress(bookUrl: String, chapterIndex: Int) {
updateBook(bookUrl) { current ->
current.copy(chapterProgress = current.chapterProgress - chapterIndex)
}
}
fun markBookFailed(bookUrl: String, message: String) { fun markBookFailed(bookUrl: String, message: String) {
updateBook(bookUrl) { current -> updateBook(bookUrl) { current ->
current.copy( current.copy(
@@ -90,6 +108,7 @@ class CacheDownloadStateStore {
waitingCount = 0, waitingCount = 0,
runningIndices = emptySet(), runningIndices = emptySet(),
successCount = 0, successCount = 0,
chapterProgress = emptyMap(),
) )
} }
.filterValues { bookState -> .filterValues { bookState ->
@@ -410,6 +410,14 @@ private fun BookCacheChapterRow(
LegadoTheme.colorScheme.onSurfaceVariant LegadoTheme.colorScheme.onSurfaceVariant
} }
) )
if (item.isDownloading) {
AppLinearProgressIndicator(
progress = item.downloadProgress,
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp)
)
}
} }
if (item.isWaiting || item.isDownloading) { if (item.isWaiting || item.isDownloading) {
SmallTonalButton( SmallTonalButton(
@@ -433,6 +441,9 @@ private fun BookCacheChapterRow(
} }
private fun chapterStatusText(item: BookCacheChapterItem): String { private fun chapterStatusText(item: BookCacheChapterItem): String {
if (item.isDownloading && !item.progressLabel.isNullOrBlank()) {
return item.progressLabel
}
return when { return when {
item.isDownloading -> "下载中" item.isDownloading -> "下载中"
item.isWaiting -> "等待下载" item.isWaiting -> "等待下载"
@@ -12,9 +12,14 @@ import io.legado.app.domain.usecase.CacheBookChaptersUseCase
import io.legado.app.domain.usecase.ClearBookCacheUseCase import io.legado.app.domain.usecase.ClearBookCacheUseCase
import io.legado.app.help.book.BookHelp import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.isAudio import io.legado.app.help.book.isAudio
import io.legado.app.help.book.isImage
import io.legado.app.help.book.isLocal import io.legado.app.help.book.isLocal
import io.legado.app.help.book.isNotShelf import io.legado.app.help.book.isNotShelf
import io.legado.app.model.CacheBook import io.legado.app.model.CacheBook
import io.legado.app.model.CacheBookModel
import io.legado.app.model.cache.CacheBookDownloadState
import io.legado.app.model.cache.CacheChapterProgress
import io.legado.app.model.cache.CacheChapterProgressPhase
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
@@ -71,6 +76,8 @@ data class BookCacheChapterItem(
val isDownloading: Boolean, val isDownloading: Boolean,
val isPaused: Boolean, val isPaused: Boolean,
val isError: Boolean, val isError: Boolean,
val downloadProgress: Float? = null,
val progressLabel: String? = null,
) )
sealed interface BookCacheManageIntent { sealed interface BookCacheManageIntent {
@@ -163,7 +170,13 @@ class BookCacheManageViewModel(
} }
viewModelScope.launch { viewModelScope.launch {
CacheBook.downloadStateFlow.collect { state -> CacheBook.downloadStateFlow.collect { state ->
state.books.keys.forEach { scheduleDownloadStatusRefresh(it) } state.books.forEach { (bookUrl, bookState) ->
if (bookState.runningIndices.isNotEmpty() || bookState.chapterProgress.isNotEmpty()) {
scheduleProgressUpdate(bookUrl, debounceMillis = 100)
} else {
scheduleDownloadStatusRefresh(bookUrl)
}
}
pendingDownloadSummaryRefresh = true pendingDownloadSummaryRefresh = true
} }
} }
@@ -257,6 +270,20 @@ class BookCacheManageViewModel(
} }
} }
private fun scheduleProgressUpdate(bookUrl: String, debounceMillis: Long = 100) {
if (bookUrl.isBlank()) return
bookReloadJobs.remove(bookUrl)?.cancel()
bookReloadJobs[bookUrl] = viewModelScope.launch {
if (debounceMillis > 0) {
delay(debounceMillis)
}
updateDownloadProgressOnly(bookUrl)
if (bookReloadJobs[bookUrl] == currentCoroutineContext()[Job]) {
bookReloadJobs.remove(bookUrl)
}
}
}
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()
@@ -334,7 +361,11 @@ class BookCacheManageViewModel(
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") }
val cachedCount = min(cachedFileCount + bookChapterDao.getVolumeCount(book.bookUrl), totalCount) val cachedCount = if (book.isImage) {
min(BookHelp.countImageCachedChapters(book), totalCount)
} else {
min(cachedFileCount + bookChapterDao.getVolumeCount(book.bookUrl), totalCount)
}
if (totalCount == 0 && cacheFiles.isEmpty() && waitingCount == 0 && downloadingCount == 0 && pausedCount == 0 && !book.isNotShelf) { if (totalCount == 0 && cacheFiles.isEmpty() && waitingCount == 0 && downloadingCount == 0 && pausedCount == 0 && !book.isNotShelf) {
return null return null
} }
@@ -362,22 +393,71 @@ 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 bookState = CacheBook.downloadStateFlow.value.books[bookUrl]
val errorIndices = errorIndices(bookUrl) val errorIndices = errorIndices(bookUrl)
return chapters.map { chapter -> return chapters.map { chapter ->
val isPaused = model?.isPaused(chapter.index) == true val isPaused = model?.isPaused(chapter.index) == true
val isWaiting = !isPaused && model?.isWaiting(chapter.index) == true
val isDownloading = !isPaused && model?.isDownloading(chapter.index) == true
val bookChapter = BookChapter(
bookUrl = book.bookUrl,
url = chapter.url,
title = chapter.title,
isVolume = chapter.isVolume,
index = chapter.index,
)
val isCached = if (book.isImage) {
when {
chapter.isVolume -> true
isDownloading -> false
else -> BookHelp.hasImageFilesCached(book, bookChapter)
}
} else {
cacheFiles.contains(chapter.getFileName()) || chapter.isVolume
}
val chapterProgress = bookState?.chapterProgress?.get(chapter.index)
val (downloadProgress, progressLabel) = chapterProgressUi(
isDownloading = isDownloading,
progress = chapterProgress,
)
BookCacheChapterItem( BookCacheChapterItem(
chapterUrl = chapter.url, chapterUrl = chapter.url,
title = chapter.title, title = chapter.title,
index = chapter.index, index = chapter.index,
isCached = cacheFiles.contains(chapter.getFileName()) || chapter.isVolume, isCached = isCached,
isWaiting = !isPaused && model?.isWaiting(chapter.index) == true, isWaiting = isWaiting,
isDownloading = !isPaused && model?.isDownloading(chapter.index) == true, isDownloading = isDownloading,
isPaused = isPaused, isPaused = isPaused,
isError = errorIndices.contains(chapter.index), isError = errorIndices.contains(chapter.index),
downloadProgress = downloadProgress,
progressLabel = progressLabel,
) )
} }
} }
private fun chapterProgressUi(
isDownloading: Boolean,
progress: CacheChapterProgress?,
): Pair<Float?, String?> {
if (!isDownloading || progress == null) return null to null
return when (progress.phase) {
CacheChapterProgressPhase.CONTENT -> {
if (progress.completed >= progress.total && progress.total > 0) {
progress.fraction to "正文已下载"
} else {
null to "下载正文"
}
}
CacheChapterProgressPhase.IMAGES -> {
if (progress.total <= 0) {
null to "下载图片"
} else {
progress.fraction to "图片 ${progress.completed}/${progress.total}"
}
}
}
}
private fun errorIndices(bookUrl: String): Set<Int> { private fun errorIndices(bookUrl: String): Set<Int> {
return CacheBook.errorIndices(bookUrl) return CacheBook.errorIndices(bookUrl)
} }
@@ -502,7 +582,7 @@ class BookCacheManageViewModel(
for (chapter in bookChapterDao.getChapterCacheInfoList(bookUrl)) { for (chapter in bookChapterDao.getChapterCacheInfoList(bookUrl)) {
if ( if (
chapter.isVolume || chapter.isVolume ||
cacheFiles.contains(chapter.getFileName()) || isChapterFullyCached(book, chapter, cacheFiles) ||
model?.isPaused(chapter.index) == true || model?.isPaused(chapter.index) == true ||
model?.isWaiting(chapter.index) == true || model?.isWaiting(chapter.index) == true ||
model?.isDownloading(chapter.index) == true model?.isDownloading(chapter.index) == true
@@ -520,6 +600,93 @@ class BookCacheManageViewModel(
} }
} }
private fun isChapterFullyCached(
book: Book,
chapter: BookChapterCacheInfo,
cacheFiles: Set<String>,
): Boolean {
if (chapter.isVolume) return true
val bookChapter = BookChapter(
bookUrl = book.bookUrl,
url = chapter.url,
title = chapter.title,
isVolume = chapter.isVolume,
index = chapter.index,
)
return if (book.isImage) {
BookHelp.hasImageFilesCached(book, bookChapter)
} else {
cacheFiles.contains(chapter.getFileName())
}
}
private fun updateDownloadProgressOnly(bookUrl: String) {
val bookState = CacheBook.downloadStateFlow.value.books[bookUrl] ?: return
val model = CacheBook.cacheBookMap[bookUrl]
_uiState.update { state ->
fun updateBookItem(item: BookCacheBookItem): BookCacheBookItem {
if (item.bookUrl != bookUrl) return item
return applyDownloadStateToBookItem(item, bookState, model)
}
val updatedChapters = state.chaptersByBookUrl[bookUrl]?.map { item ->
val isPaused = model?.isPaused(item.index) == true
val isWaiting = !isPaused && model?.isWaiting(item.index) == true
val isDownloading = !isPaused && model?.isDownloading(item.index) == true
val chapterProgress = bookState.chapterProgress[item.index]
val (downloadProgress, progressLabel) = chapterProgressUi(
isDownloading = isDownloading,
progress = chapterProgress,
)
item.copy(
isWaiting = isWaiting,
isDownloading = isDownloading,
isPaused = isPaused,
downloadProgress = downloadProgress,
progressLabel = progressLabel,
)
}
val shelfBooks = state.shelfBooks.map(::updateBookItem)
val notShelfBooks = state.notShelfBooks.map(::updateBookItem)
state.copy(
shelfBooks = shelfBooks,
notShelfBooks = notShelfBooks,
chaptersByBookUrl = if (updatedChapters != null) {
state.chaptersByBookUrl + (bookUrl to updatedChapters)
} else {
state.chaptersByBookUrl
},
downloadSummary = buildDownloadSummary(shelfBooks + notShelfBooks),
hasPausedDownloads = CacheBook.hasPausedDownloads,
version = state.version + 1,
)
}
}
private fun applyDownloadStateToBookItem(
item: BookCacheBookItem,
bookState: CacheBookDownloadState,
model: CacheBookModel?,
): BookCacheBookItem {
val rawWaitingCount = bookState.waitingCount +
CacheBook.pendingAdmissionFlow.value[item.bookUrl].orZero()
val rawDownloadingCount = bookState.runningIndices.size
val isBookPaused = model?.isPaused() == true ||
(CacheBook.hasPausedDownloads && CacheBook.pendingAdmissionFlow.value.containsKey(item.bookUrl))
val pausedCount = if (isBookPaused) {
rawWaitingCount + rawDownloadingCount + bookState.pausedIndices.size
} else {
bookState.pausedIndices.size
}
val waitingCount = if (isBookPaused) 0 else rawWaitingCount
val downloadingCount = if (isBookPaused) 0 else rawDownloadingCount
return item.copy(
waitingCount = waitingCount,
downloadingCount = downloadingCount,
pausedCount = pausedCount,
errorCount = errorIndices(item.bookUrl).size,
)
}
private fun deleteBookCache(bookUrl: String) { private fun deleteBookCache(bookUrl: String) {
execute { execute {
CacheBook.removeAwait(context, bookUrl) CacheBook.removeAwait(context, bookUrl)
@@ -83,4 +83,25 @@ class CacheDownloadStateStoreTest {
assertEquals(0, store.state.totalWaiting) assertEquals(0, store.state.totalWaiting)
assertEquals(0, store.state.totalRunning) assertEquals(0, store.state.totalRunning)
} }
@Test
fun chapterProgressUpdatesAndClearsOnSuccess() {
val store = CacheDownloadStateStore()
store.updateBookQueue("a", waitingCount = 0, runningIndices = setOf(3))
store.updateChapterProgress(
"a",
3,
CacheChapterProgress(CacheChapterProgressPhase.IMAGES, completed = 2, total = 10),
)
val progress = store.state.books.getValue("a").chapterProgress.getValue(3)
assertEquals(CacheChapterProgressPhase.IMAGES, progress.phase)
assertEquals(2, progress.completed)
assertEquals(10, progress.total)
store.markSuccess("a", 3)
assertFalse(store.state.books.getValue("a").chapterProgress.containsKey(3))
}
} }