Jelajahi Sumber

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>
AugusChen 4 bulan lalu
induk
melakukan
44b1b582c0

+ 50 - 3
app/src/main/java/io/legado/app/help/book/BookHelp.kt

@@ -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)) {

+ 42 - 2
app/src/main/java/io/legado/app/help/glide/OkHttpStreamFetcher.kt

@@ -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) {

+ 117 - 7
app/src/main/java/io/legado/app/model/CacheBookModel.kt

@@ -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)
-            (it as? String)?.let { content ->
-                emitPendingReadContent(chapter, content)
+            if (chainImagesAfterContent && book.isImage && !repository.hasImageContent(book, chapter)) {
+                startImageCacheTask(scope, context, chapter, chapterIndex, it as String)
+                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)

+ 19 - 2
app/src/main/java/io/legado/app/model/ReadManga.kt

@@ -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

+ 15 - 0
app/src/main/java/io/legado/app/model/cache/CacheDownloadModels.kt

@@ -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
+}

+ 13 - 3
app/src/main/java/io/legado/app/model/cache/CacheDownloadRepository.kt

@@ -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 {
-                BookHelp.saveImages(bookSource, book, chapter, it, 1)
-            }
+            saveCachedImagesAwait(bookSource, book, chapter, onProgress)
+        }
+    }
+
+    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)
         }
         }
     }
     }
 
 

+ 19 - 0
app/src/main/java/io/legado/app/model/cache/CacheDownloadStateStore.kt

@@ -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 ->

+ 11 - 0
app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageScreen.kt

@@ -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 -> "等待下载"

+ 173 - 6
app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageViewModel.kt

@@ -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,
-                isWaiting = !isPaused && model?.isWaiting(chapter.index) == true,
-                isDownloading = !isPaused && model?.isDownloading(chapter.index) == true,
+                isCached = isCached,
+                isWaiting = isWaiting,
+                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)

+ 21 - 0
app/src/test/java/io/legado/app/model/cache/CacheDownloadStateStoreTest.kt

@@ -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))
+    }
 }
 }