Procházet zdrojové kódy

fix(cache): 对齐图片缓存成功判定,并完善多书 FIFO 独占调度

Squash merge PR #1872
AugusChen před 3 měsíci
rodič
revize
80d6c830e7
26 změnil soubory, kde provedl 1399 přidání a 210 odebrání
  1. 2 0
      app/src/main/java/io/legado/app/constant/IntentAction.kt
  2. 49 15
      app/src/main/java/io/legado/app/help/book/BookHelp.kt
  3. 9 0
      app/src/main/java/io/legado/app/help/book/ChapterImageCache.kt
  4. 166 32
      app/src/main/java/io/legado/app/model/CacheBook.kt
  5. 147 45
      app/src/main/java/io/legado/app/model/CacheBookModel.kt
  6. 26 14
      app/src/main/java/io/legado/app/model/ReadManga.kt
  7. 8 0
      app/src/main/java/io/legado/app/model/cache/CacheDownloadAdmissionQueue.kt
  8. 85 10
      app/src/main/java/io/legado/app/model/cache/CacheDownloadQueue.kt
  9. 6 2
      app/src/main/java/io/legado/app/model/cache/CacheDownloadRepository.kt
  10. 62 0
      app/src/main/java/io/legado/app/model/cache/ExplicitCacheBookFifo.kt
  11. 62 24
      app/src/main/java/io/legado/app/service/CacheBookService.kt
  12. 19 0
      app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheListSort.kt
  13. 19 10
      app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageScreen.kt
  14. 159 58
      app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageViewModel.kt
  15. 34 0
      app/src/main/java/io/legado/app/ui/book/cache/manage/ChapterCacheStatus.kt
  16. 1 0
      app/src/main/res/values-zh-rCN/strings.xml
  17. 1 0
      app/src/main/res/values-zh-rHK/strings.xml
  18. 1 0
      app/src/main/res/values-zh-rTW/strings.xml
  19. 1 0
      app/src/main/res/values/strings.xml
  20. 16 0
      app/src/test/java/io/legado/app/help/book/BookHelpImageCacheCompleteTest.kt
  21. 183 0
      app/src/test/java/io/legado/app/model/CacheBookModelResumeTest.kt
  22. 29 0
      app/src/test/java/io/legado/app/model/cache/CacheDownloadAdmissionQueueTest.kt
  23. 22 0
      app/src/test/java/io/legado/app/model/cache/CacheDownloadQueueTest.kt
  24. 169 0
      app/src/test/java/io/legado/app/model/cache/ExplicitCacheBookFifoTest.kt
  25. 71 0
      app/src/test/java/io/legado/app/ui/book/cache/manage/BookCacheListSortTest.kt
  26. 52 0
      app/src/test/java/io/legado/app/ui/book/cache/manage/ChapterCacheStatusTest.kt

+ 2 - 0
app/src/main/java/io/legado/app/constant/IntentAction.kt

@@ -7,6 +7,8 @@ object IntentAction {
     const val playNew = "playNew"
     const val stop = "stop"
     const val resume = "resume"
+    /** 仅清除全局暂停并继续调度,不解冻各书已单章暂停的章节 */
+    const val continueDownload = "continueDownload"
     const val pause = "pause"
     const val addTimer = "addTimer"
     const val setTimer = "setTimer"

+ 49 - 15
app/src/main/java/io/legado/app/help/book/BookHelp.kt

@@ -298,6 +298,9 @@ object BookHelp {
         return count
     }
 
+    /**
+     * @return 失败的图片数量(0 表示全部成功或无需下载)
+     */
     suspend fun saveImages(
         bookSource: BookSource,
         book: Book,
@@ -305,30 +308,37 @@ object BookHelp {
         content: String,
         concurrency: Int = OtherConfig.threadCount,
         onProgress: (suspend (completed: Int, total: Int) -> Unit)? = null,
-    ) = coroutineScope {
+    ): Int = coroutineScope {
         val imageUrls = flowImages(bookChapter, content).toList()
         val total = imageUrls.size
         onProgress?.invoke(0, total)
-        if (total == 0) return@coroutineScope
+        if (total == 0) return@coroutineScope 0
         val progressMutex = Mutex()
         var completed = 0
+        var failures = 0
         imageUrls.asFlow().onEachParallel(concurrency) { mSrc ->
-            saveImage(bookSource, book, mSrc, bookChapter)
+            val ok = saveImage(bookSource, book, mSrc, bookChapter)
             progressMutex.withLock {
+                if (!ok) failures++
                 completed++
                 onProgress?.invoke(completed, total)
             }
         }.collect()
+        failures
     }
 
+    /**
+     * @return true 表示图片已存在,或本次下载且校验通过。
+     * 数据异常时仍会落盘(避免反复拉坏图),但返回 false 以便调用方记失败。
+     */
     suspend fun saveImage(
         bookSource: BookSource?,
         book: Book,
         src: String,
         chapter: BookChapter? = null
-    ) {
+    ): Boolean {
         if (isImageExist(book, src)) {
-            return
+            return true
         }
         val mutex = synchronized(this) {
             downloadImages.getOrPut(src) { Mutex() }
@@ -336,7 +346,7 @@ object BookHelp {
         mutex.lock()
         try {
             if (isImageExist(book, src)) {
-                return
+                return true
             }
             imageDownloadSlots.acquire()
             try {
@@ -347,24 +357,29 @@ object BookHelp {
                     analyzeUrl.getInputStreamAwait().use {
                         writeImage(book, src, it)
                     }
+                    return true
                 } else {
                     imageDecodeSlots.acquire()
                     try {
                         val bytes = analyzeUrl.getByteArrayAwait()
                         //某些图片被加密,需要进一步解密
-                        runScriptWithContext {
+                        val decoded = runScriptWithContext {
                             ImageUtils.decode(
                                 src, bytes, isCover = false, bookSource, book
                             )
-                        }?.let {
-                            if (!checkImage(it)) {
-                                // 如果部分图片失效,每次进入正文都会花很长时间再次获取图片数据
-                                // 所以无论如何都要将数据写入到文件里
-                                // throw NoStackTraceException("数据异常")
-                                AppLog.put("${book.name} ${chapter?.title} 图片 $src 下载错误 数据异常")
-                            }
-                            writeImage(book, src, it)
                         }
+                        if (decoded == null) {
+                            AppLog.put("${book.name} ${chapter?.title} 图片 $src 下载失败 解码为空")
+                            return false
+                        }
+                        // 如果部分图片失效,每次进入正文都会花很长时间再次获取图片数据
+                        // 所以无论如何都要将数据写入到文件里;但仍记失败,避免章节被标为已缓存
+                        writeImage(book, src, decoded)
+                        if (!checkImage(decoded)) {
+                            AppLog.put("${book.name} ${chapter?.title} 图片 $src 下载错误 数据异常")
+                            return false
+                        }
+                        return true
                     } finally {
                         imageDecodeSlots.release()
                     }
@@ -376,12 +391,31 @@ object BookHelp {
             currentCoroutineContext().ensureActive()
             val msg = "${book.name} ${chapter?.title} 图片 $src 下载失败\n${e.localizedMessage}"
             AppLog.put(msg, e)
+            return false
         } finally {
             downloadImages.remove(src)
             mutex.unlock()
         }
     }
 
+    /**
+     * 一次 saveImages 批次是否可视为章节图片缓存完成。
+     */
+    fun isChapterImageCacheComplete(failures: Int, filesCached: Boolean): Boolean {
+        return io.legado.app.help.book.isChapterImageCacheComplete(failures, filesCached)
+    }
+
+    fun isChapterImageCacheComplete(
+        book: Book,
+        bookChapter: BookChapter,
+        failures: Int,
+    ): Boolean {
+        return io.legado.app.help.book.isChapterImageCacheComplete(
+            failures,
+            hasImageFilesCached(book, bookChapter),
+        )
+    }
+
     fun getImage(book: Book, src: String): File {
         return downloadDir.getFile(
             cacheFolderName,

+ 9 - 0
app/src/main/java/io/legado/app/help/book/ChapterImageCache.kt

@@ -0,0 +1,9 @@
+package io.legado.app.help.book
+
+/**
+ * 一次图片缓存批次是否可视为章节图片缓存完成。
+ * 纯函数,可供 JVM 单元测试直接验证,不依赖 Android 运行时。
+ */
+fun isChapterImageCacheComplete(failures: Int, filesCached: Boolean): Boolean {
+    return failures == 0 && filesCached
+}

+ 166 - 32
app/src/main/java/io/legado/app/model/CacheBook.kt

@@ -12,6 +12,7 @@ import io.legado.app.help.book.isLocal
 import io.legado.app.model.cache.CacheDownloadRequest
 import io.legado.app.model.cache.CacheDownloadStateStore
 import io.legado.app.model.cache.ChapterSelection
+import io.legado.app.model.cache.ExplicitCacheBookFifo
 import io.legado.app.service.CacheBookService
 import io.legado.app.ui.config.otherConfig.OtherConfig
 import io.legado.app.utils.LogUtils
@@ -71,23 +72,71 @@ object CacheBook {
 
         suspend fun startProcessJob(context: CoroutineContext) = processMutex.withLock {
             setWorkingState(true)
+            val concurrency = OtherConfig.cacheBookThreadCount.coerceIn(1, maxDownloadConcurrency)
             flow {
                 while (currentCoroutineContext().isActive && taskMap.isNotEmpty() && !isPaused) {
                     if (!workingState.value) {
                         workingState.first { it }
                     }
                     var emitted = false
-                    taskMap.forEach { (_, model) ->
+                    // 显式离线缓存:仅调度 FIFO 队首,避免多书章节交错抢线程。
+                    // 锁序:禁止在持有 explicitFifo 时调用 CacheBookModel(@Synchronized)。
+                    // 显式书必须在暴露 launchable 之前 ensure(见 addRequest / ensureExplicitFifo)。
+                    fun selectExclusiveFifoHead(order: List<String>): String? {
+                        // 独占队首:仍有进行中/重试/可调度任务的书,即使 waiting 暂时为空也不能跳过。
+                        return order.firstOrNull { bookUrl ->
+                            val model = taskMap[bookUrl] ?: return@firstOrNull false
+                            model.hasRunnableDownloads() || model.isWaitingRetry()
+                        }
+                    }
+                    var fifoOrder = synchronized(explicitFifo) { explicitFifo.snapshot() }
+                    var fifoHead = selectExclusiveFifoHead(fifoOrder)
+                    // 冷启动并行准入:本轮 snapshot 时 FIFO 可能还是空的,随后已 ensure;
+                    // 队首为空时再取一次,避免空等一整拍。
+                    if (fifoHead == null) {
+                        fifoOrder = synchronized(explicitFifo) { explicitFifo.snapshot() }
+                        fifoHead = selectExclusiveFifoHead(fifoOrder)
+                    }
+                    if (fifoHead != null) {
+                        val headModel = taskMap[fifoHead]
+                        // 仅当队首还有可新启动的章节时才 emit;进行中则空转等待,不轮到下一本
+                        if (headModel != null && headModel.hasLaunchableChapters()) {
+                            repeat(concurrency) {
+                                if (headModel.hasLaunchableChapters()) {
+                                    emit(headModel)
+                                    emitted = true
+                                }
+                            }
+                        }
+                    }
+                    // 阅读器预下载等非 FIFO 书可与队首并行;显式书由上面独占调度。
+                    // 必须用「此刻」的 live snapshot 排除:若用本轮初空 snapshot,刚 ensure 的书
+                    // 会漏出并与队首抢满并发(冷启动第一次 FAB 必现 7+1)。
+                    // 不要用 fifo.isEmpty() 整段关闭——FIFO 非空时仍应允许真正的预下载。
+                    val explicitBookUrls =
+                        synchronized(explicitFifo) { explicitFifo.snapshot().toHashSet() }
+                    taskMap.forEach { (bookUrl, model) ->
+                        if (bookUrl in explicitBookUrls) return@forEach
                         if (model.hasLaunchableChapters()) {
                             emit(model)
                             emitted = true
                         }
                     }
-                    if (!emitted) delay(800)
+                    if (!emitted) {
+                        val keepWaiting = taskMap.values.any {
+                            it.isLoading() || it.isWaitingRetry() || it.hasRunnableDownloads()
+                        }
+                        if (keepWaiting) {
+                            delay(800)
+                        } else {
+                            // 仅剩暂停书籍时退出,让 Service 继续 drain 下一本
+                            break
+                        }
+                    }
                 }
             }.onStart {
                 updateSummary()
-            }.onEachParallel(OtherConfig.cacheBookThreadCount.coerceIn(1, maxDownloadConcurrency)) {
+            }.onEachParallel(concurrency) {
                 coroutineScope {
                     it.download(this, context)
                 }
@@ -99,6 +148,7 @@ object CacheBook {
 
     private val modelHost = ModelHostImpl()
     private val coordinator = CacheBookCoordinator()
+    private val explicitFifo = ExplicitCacheBookFifo()
     private val stateStore = CacheDownloadStateStore()
     private val pendingRemoveRequests = ConcurrentHashMap<Long, CompletableDeferred<Boolean>>()
     private val pendingRequestId = AtomicLong(0)
@@ -358,6 +408,7 @@ object CacheBook {
     internal fun removeBookFromService(bookUrl: String): Boolean {
         val model = cacheBookMap.remove(bookUrl)
         model?.stop()
+        synchronized(explicitFifo) { explicitFifo.remove(bookUrl) }
         removePendingAdmission(bookUrl)
         stateStore.removeBook(bookUrl)
         updateSummary()
@@ -369,6 +420,7 @@ object CacheBook {
         val removed = cacheBookMap.remove(bookUrl, model)
         if (!removed) return false
         model.stop()
+        synchronized(explicitFifo) { explicitFifo.remove(bookUrl) }
         stateStore.removeBook(bookUrl)
         updateSummary()
         _queueChangedFlow.tryEmit(bookUrl)
@@ -405,12 +457,14 @@ object CacheBook {
 
     fun resume(context: Context): Boolean {
         if (!hasQueuedDownloads) return false
-        isPaused = false
+        // 先同步解冻所有书,避免仅依赖 Service 异步 Intent 时 UI/调度只看到队首
+        resumeFromService()
         val started = startCacheBookService(context) {
             action = IntentAction.resume
         }
         if (!started) {
-            resumeFromService()
+            // Service 未拉起时 resumeFromService 已处理;仍确保全局标志
+            isPaused = false
         }
         return true
     }
@@ -434,9 +488,17 @@ object CacheBook {
         cacheBookMap.keys.forEach { _queueChangedFlow.tryEmit(it) }
     }
 
+    /** 清除全局暂停并唤醒调度,不调用各书 [CacheBookModel.resume](保留单章暂停)。 */
+    internal fun clearGlobalPauseFromService() {
+        isPaused = false
+        updateSummary()
+        cacheBookMap.keys.forEach { _queueChangedFlow.tryEmit(it) }
+    }
+
     fun pauseBook(context: Context, bookUrl: String): Boolean {
         val paused = cacheBookMap[bookUrl]?.pause() == true
         if (paused) {
+            // 暂停让位:FIFO 顺序保留;processJob 跳过该书后退出,Service 继续 drain 下一本
             updateSummary()
             _queueChangedFlow.tryEmit(bookUrl)
         }
@@ -444,24 +506,25 @@ object CacheBook {
     }
 
     fun resumeBook(context: Context, bookUrl: String): Boolean {
-        val resumed = cacheBookMap[bookUrl]?.resume() == true
-        if (resumed) {
-            isPaused = false
-            updateSummary()
-            _queueChangedFlow.tryEmit(bookUrl)
-            val started = startCacheBookService(context) {
-                action = IntentAction.resume
-            }
-            if (!started) {
-                resumeFromService()
-            }
-        }
-        return resumed
+        val model = cacheBookMap[bookUrl] ?: return false
+        if (!model.isPaused() && model.pausedIndices().isEmpty()) return false
+        // 在 resume 前判断:避免把自己算进「正在下载」
+        val hasActiveOther = hasActiveExplicitDownloadBesides(bookUrl)
+        val resumed = model.resume()
+        if (!resumed) return false
+        repositionExplicitBookOnResume(bookUrl, hasActiveOther)
+        isPaused = false
+        updateSummary()
+        _queueChangedFlow.tryEmit(bookUrl)
+        // 不可用 IntentAction.resume:会 resume() 解冻其它已暂停书籍
+        kickContinueDownload(context)
+        return true
     }
 
     fun pauseChapter(bookUrl: String, chapterIndex: Int): Boolean {
         val paused = cacheBookMap[bookUrl]?.pauseDownload(chapterIndex) == true
         if (paused) {
+            // 仅剩暂停章节时 processJob 会让位给下一本
             updateSummary()
             _queueChangedFlow.tryEmit(bookUrl)
         }
@@ -469,25 +532,66 @@ object CacheBook {
     }
 
     fun resumeChapter(context: Context, bookUrl: String, chapterIndex: Int): Boolean {
-        val resumed = cacheBookMap[bookUrl]?.resumeDownload(chapterIndex) == true
-        if (resumed) {
-            isPaused = false
-            updateSummary()
-            _queueChangedFlow.tryEmit(bookUrl)
-            val started = startCacheBookService(context) {
-                action = IntentAction.resume
-            }
-            if (!started) {
-                resumeFromService()
+        val model = cacheBookMap[bookUrl] ?: return false
+        if (!model.isPaused(chapterIndex)) return false
+        val hasActiveOther = hasActiveExplicitDownloadBesides(bookUrl)
+        val resumed = model.resumeDownload(chapterIndex)
+        if (!resumed) return false
+        repositionExplicitBookOnResume(bookUrl, hasActiveOther)
+        isPaused = false
+        updateSummary()
+        _queueChangedFlow.tryEmit(bookUrl)
+        // 不可用 IntentAction.resume:会 model.resume() 解冻其它暂停书/其余单章
+        kickContinueDownload(context)
+        return true
+    }
+
+    /**
+     * 是否存在其它显式离线缓存书正在占槽(不含 [bookUrl])。
+     * 只看 [explicitFifo] + [CacheBookModel.hasInFlightDownloads],忽略:
+     * - 阅读器预下载等非 FIFO 任务
+     * - 仅 waiting 的后续书(独占 FIFO 下它们本来就会干等,不能当成「在下」)
+     */
+    private fun hasActiveExplicitDownloadBesides(bookUrl: String): Boolean {
+        // 先 snapshot 再判 model,避免持有 fifo 锁时调用 @Synchronized model(ABBA)
+        val others = synchronized(explicitFifo) {
+            explicitFifo.urlsBesides(bookUrl)
+        }
+        return others.any { url ->
+            cacheBookMap[url]?.hasInFlightDownloads() == true
+        }
+    }
+
+    /**
+     * 恢复后的 FIFO 定位:有其它书正在占槽则队尾(不插队);
+     * 无人占槽(含全暂停、或仅有后续书 waiting)则队首(点哪本先下哪本)。
+     */
+    private fun repositionExplicitBookOnResume(bookUrl: String, hasActiveOther: Boolean) {
+        synchronized(explicitFifo) {
+            if (!explicitFifo.contains(bookUrl)) return
+            if (hasActiveOther) {
+                explicitFifo.moveToTail(bookUrl)
+            } else {
+                explicitFifo.moveToHead(bookUrl)
             }
         }
-        return resumed
+    }
+
+    /** 清除全局暂停并唤醒调度,不解冻其它书的暂停状态。 */
+    private fun kickContinueDownload(context: Context) {
+        val started = startCacheBookService(context) {
+            action = IntentAction.continueDownload
+        }
+        if (!started) {
+            clearGlobalPauseFromService()
+        }
     }
 
     fun close(clearFailureState: Boolean = false) {
         isPaused = false
         cacheBookMap.forEach { (_, model) -> model.stop() }
         cacheBookMap.clear()
+        synchronized(explicitFifo) { explicitFifo.clear() }
         successDownloadCount.set(0)
         pendingRemoveRequests.values.forEach { it.complete(false) }
         pendingRemoveRequests.clear()
@@ -511,7 +615,10 @@ object CacheBook {
             toRemove.add(bookUrl)
             model.stop()
         }
-        toRemove.forEach { cacheBookMap.remove(it) }
+        toRemove.forEach {
+            cacheBookMap.remove(it)
+            synchronized(explicitFifo) { explicitFifo.remove(it) }
+        }
         successDownloadCount.set(0)
         pendingRemoveRequests.values.forEach { it.complete(false) }
         pendingRemoveRequests.clear()
@@ -547,7 +654,9 @@ object CacheBook {
         get() = !isPaused && (
                 lastQueueStats.waitingCount > 0 ||
                         lastQueueStats.downloadingCount > 0 ||
-                        cacheBookMap.values.any { it.hasQueuedDownloads() }
+                        cacheBookMap.values.any {
+                            it.hasRunnableDownloads() || it.isLoading() || it.isWaitingRetry()
+                        }
                 )
 
     val hasQueuedDownloads: Boolean
@@ -555,7 +664,9 @@ object CacheBook {
                 _pendingAdmissionFlow.value.isNotEmpty()
 
     val hasPausedDownloads: Boolean
-        get() = (isPaused && hasQueuedDownloads) || cacheBookMap.values.any { it.isPaused() }
+        get() = (isPaused && hasQueuedDownloads) || cacheBookMap.values.any {
+            it.isPaused() || it.pausedIndices().isNotEmpty()
+        }
 
     val isGloballyPaused: Boolean
         get() = isPaused && hasQueuedDownloads
@@ -654,6 +765,7 @@ object CacheBook {
 
     private fun notifyTaskRemoved(bookUrl: String, clearState: Boolean = false) {
         cacheBookMap.remove(bookUrl)
+        synchronized(explicitFifo) { explicitFifo.remove(bookUrl) }
         if (clearState) {
             stateStore.removeBook(bookUrl)
         }
@@ -661,6 +773,25 @@ object CacheBook {
         _queueChangedFlow.tryEmit(bookUrl)
     }
 
+    private fun onExplicitBookQueued(bookUrl: String) {
+        ensureExplicitFifo(bookUrl)
+    }
+
+    /**
+     * 显式离线缓存尽早登记 FIFO(准入/setLoading 之前即可)。
+     * 避免 getOrCreate 已进 taskMap、尚未 addRequest ensure 时被当成预下载并行。
+     */
+    internal fun ensureExplicitFifo(bookUrl: String) {
+        synchronized(explicitFifo) {
+            explicitFifo.ensure(bookUrl)
+        }
+    }
+
+    /** 显式离线缓存的书籍排队顺序(FIFO),供缓存管理列表稳定排序。 */
+    fun explicitDownloadOrder(): List<String> {
+        return synchronized(explicitFifo) { explicitFifo.snapshot() }
+    }
+
     private class ModelHostImpl : CacheBookModel.Host {
         override val stateStore: CacheDownloadStateStore
             get() = CacheBook.stateStore
@@ -674,6 +805,9 @@ object CacheBook {
         override fun onTaskRemoved(bookUrl: String, clearState: Boolean) {
             CacheBook.notifyTaskRemoved(bookUrl, clearState)
         }
+        override fun onExplicitBookQueued(bookUrl: String) {
+            CacheBook.onExplicitBookQueued(bookUrl)
+        }
         override fun emitDownloadingIndices(bookUrl: String, indices: Set<Int>) {
             CacheBook._downloadingIndicesFlow.tryEmit(bookUrl to indices)
         }

+ 147 - 45
app/src/main/java/io/legado/app/model/CacheBookModel.kt

@@ -44,6 +44,7 @@ class CacheBookModel(
         fun incrementSuccessCount(): Int
         fun onTaskQueuesChanged(bookUrl: String)
         fun onTaskRemoved(bookUrl: String, clearState: Boolean = false)
+        fun onExplicitBookQueued(bookUrl: String)
         fun emitDownloadingIndices(bookUrl: String, indices: Set<Int>)
         fun emitDownloadError(bookUrl: String, indices: Set<Int>)
         fun emitChapterCached(chapter: BookChapter)
@@ -138,6 +139,8 @@ class CacheBookModel(
 
     fun isLoading(): Boolean = isLoading
 
+    fun isWaitingRetry(): Boolean = waitingRetry
+
     @Synchronized
     fun hasQueuedDownloads(): Boolean {
         return queue.waitingCount() > 0 ||
@@ -157,10 +160,29 @@ class CacheBookModel(
                 )
     }
 
+    /**
+     * 是否占用下载槽:正文/图片进行中、加载目录、或失败重试退避中。
+     * 不含仅 waiting——独占 FIFO 下后续书会带着 waiting 干等,不能据此判定「正在下载」。
+     * 用于恢复时是否 [moveToTail]:有其它书占槽则队尾,否则队首。
+     */
+    @Synchronized
+    fun hasInFlightDownloads(): Boolean {
+        return waitingRetry || (
+                !isPaused && (
+                        onDownloadSet.isNotEmpty() ||
+                                chapterTasks.isNotEmpty() ||
+                                isLoading
+                        )
+                )
+    }
+
     /**
      * 有可启动的下载任务(队列中有待下载章节或正在加载目录)。
      * 与 [hasRunnableDownloads] 不同,不包含已在执行中的任务。
      * 用于判断是否需要向下载循环 emit 模型,避免无新章节可取时的高频空转。
+     *
+     * 注意:多书 FIFO 选「独占队首」必须用 [hasRunnableDownloads](或重试中),
+     * 不能用本方法——否则并发下图时 waiting 暂时为空会跳过当前书去调度下一本。
      */
     @Synchronized
     fun hasLaunchableChapters(): Boolean {
@@ -190,10 +212,23 @@ class CacheBookModel(
         isPaused = true
         isLoading = false
         waitingRetry = false
+        // 整书暂停:进行中与等待中的章节都移出调度队列,转入单章暂停集合。
+        // onCancel 使用 requeue=false,避免与超时/失败路径双重入队。
+        onDownloadSet.toList().forEach { index ->
+            canceledDownloadSet.add(index)
+            pausedChapterSet.add(index)
+            host.stateStore.clearChapterProgress(book.bookUrl, index)
+        }
+        onDownloadSet.clear()
+        for (index in queue.waitingIndices()) {
+            queue.removeChapter(index)
+            pausedChapterSet.add(index)
+        }
         chapterTasks.values.toList().forEach { task ->
             tasks.delete(task)
             task.cancel()
         }
+        chapterTasks.clear()
         notifyDownloadSetChanged()
         host.onTaskQueuesChanged(book.bookUrl)
         return true
@@ -229,7 +264,6 @@ class CacheBookModel(
         host.onTaskQueuesChanged(book.bookUrl)
     }
 
-    @Synchronized
     fun addDownload(start: Int, end: Int) {
         addRequest(
             CacheDownloadRequest(
@@ -240,7 +274,6 @@ class CacheBookModel(
         )
     }
 
-    @Synchronized
     fun addDownloads(indices: Iterable<Int>) {
         val values = indices.toSet()
         if (values.isEmpty()) return
@@ -253,29 +286,52 @@ class CacheBookModel(
         )
     }
 
-    @Synchronized
     fun addRequest(request: CacheDownloadRequest) {
-        isStopped = false
-        isPaused = false
-        when (val selection = request.selection) {
-            is ChapterSelection.Range -> {
-                canceledDownloadSet.removeAll { it in selection.start..selection.end }
-                pausedChapterSet.removeAll { it in selection.start..selection.end }
+        val enqueueExplicit = request.source != CacheDownloadSource.ReadPreload
+        // 先入 FIFO,再暴露可调度章节。若反过来,冷启动多书并行准入时会出现:
+        // 已在 taskMap 且 waiting>0、尚未 ensure → processJob 当成非 FIFO 预下载并行。
+        // 锁序:仅 fifo → 释放 → model,与 startProcessJob(fifo snapshot 后调 model)不 ABBA。
+        if (enqueueExplicit) {
+            host.onExplicitBookQueued(book.bookUrl)
+        }
+        synchronized(this) {
+            isStopped = false
+            val selected = selectionIndices(request.selection)
+            // 整书暂停时再入队:只解冻本次请求的章节,其余等待章转为单章暂停
+            if (isPaused) {
+                releaseBookPauseKeepingOnly(selected)
             }
-            is ChapterSelection.Indices -> selection.values.forEach {
-                canceledDownloadSet.remove(it)
-                pausedChapterSet.remove(it)
+            isPaused = false
+            when (val selection = request.selection) {
+                is ChapterSelection.Range -> {
+                    canceledDownloadSet.removeAll { it in selection.start..selection.end }
+                    pausedChapterSet.removeAll { it in selection.start..selection.end }
+                }
+                is ChapterSelection.Indices -> selection.values.forEach {
+                    canceledDownloadSet.remove(it)
+                    pausedChapterSet.remove(it)
+                }
+                is ChapterSelection.Single -> {
+                    canceledDownloadSet.remove(selection.index)
+                    pausedChapterSet.remove(selection.index)
+                }
             }
-            is ChapterSelection.Single -> {
-                canceledDownloadSet.remove(selection.index)
-                pausedChapterSet.remove(selection.index)
+            queue.enqueue(request)
+            // 单章/少量章节提到队首,避免排在失败重试之后;大 Range 保持懒游标
+            when (val selection = request.selection) {
+                is ChapterSelection.Single -> queue.prioritize(selection.index)
+                is ChapterSelection.Indices -> {
+                    if (selection.values.size <= 16) {
+                        selection.values.toList().asReversed().forEach { queue.prioritize(it) }
+                    }
+                }
+                is ChapterSelection.Range -> Unit
             }
+            host.cacheBookMap[book.bookUrl] = this
+            isLoading = false
+            notifyDownloadSetChanged()
+            host.onTaskQueuesChanged(book.bookUrl)
         }
-        queue.enqueue(request)
-        host.cacheBookMap[book.bookUrl] = this
-        isLoading = false
-        notifyDownloadSetChanged()
-        host.onTaskQueuesChanged(book.bookUrl)
     }
 
     fun addDownload(index: Int) {
@@ -391,6 +447,7 @@ class CacheBookModel(
                 it.cancel()
             }
         }
+        host.stateStore.clearChapterProgress(book.bookUrl, index)
         notifyDownloadSetChanged()
         host.onTaskQueuesChanged(book.bookUrl)
         return true
@@ -398,14 +455,44 @@ class CacheBookModel(
 
     @Synchronized
     fun resumeDownload(index: Int): Boolean {
-        if (!pausedChapterSet.remove(index)) return false
-        isPaused = false
-        queue.enqueue(ChapterSelection.Single(index))
+        val fromChapterPause = pausedChapterSet.remove(index)
+        val bookPaused = isPaused
+        // 整书暂停时 isPaused(index) 对等待中章节为 true,但章节不在 pausedChapterSet
+        if (!fromChapterPause && !bookPaused) return false
+        if (bookPaused) {
+            releaseBookPauseKeepingOnly(setOf(index))
+        }
+        if (!queue.isWaiting(index)) {
+            queue.enqueue(ChapterSelection.Single(index))
+        }
+        queue.prioritize(index)
         notifyDownloadSetChanged()
         host.onTaskQueuesChanged(book.bookUrl)
         return true
     }
 
+    /**
+     * 解除整书暂停:仅 [keepRunnable] 留在等待队列,其余等待章转入单章暂停。
+     */
+    private fun releaseBookPauseKeepingOnly(keepRunnable: Set<Int>) {
+        if (!isPaused) return
+        for (idx in queue.waitingIndices()) {
+            if (idx !in keepRunnable) {
+                queue.removeChapter(idx)
+                pausedChapterSet.add(idx)
+            }
+        }
+        isPaused = false
+    }
+
+    private fun selectionIndices(selection: ChapterSelection): Set<Int> {
+        return when (selection) {
+            is ChapterSelection.Range -> (selection.start..selection.end).toSet()
+            is ChapterSelection.Indices -> selection.values
+            is ChapterSelection.Single -> setOf(selection.index)
+        }
+    }
+
     /**
      * 从待下载列表内取第一条下载
      */
@@ -456,7 +543,7 @@ class CacheBookModel(
             context = context,
             start = CoroutineStart.LAZY,
             executeContext = context,
-        )
+        ).timeout(DOWNLOAD_TIMEOUT_MS)
         if (!attachTaskIfActive(task, chapter, chapterIndex, scope, context, chainImagesAfterContent = true)) {
             task.cancel()
             return
@@ -482,6 +569,7 @@ class CacheBookModel(
             return null
         }
         onDownloadSet.add(chapterIndex)
+        host.stateStore.clearFailure(book.bookUrl, chapterIndex)
         notifyDownloadSetChanged()
         return candidate
     }
@@ -545,7 +633,8 @@ class CacheBookModel(
             }
             emitPendingReadError(chapter, it)
         }.onCancel(IO) {
-            onCancel(chapterIndex)
+            // 失败重试由 onError 入队;主动暂停由 pause/pauseDownload 入队或标记 canceled
+            onCancel(chapterIndex, requeue = false)
             emitPendingReadCanceled(chapter)
         }.onFinally(IO) {
             if (chapterTasks[chapterIndex] === task) {
@@ -631,6 +720,19 @@ class CacheBookModel(
         return BookHelp.countImagesInContent(chapter, content)
     }
 
+    private suspend fun ensureChapterImagesCached(chapter: BookChapter) {
+        if (!book.isImage || repository.hasImageContent(book, chapter)) return
+        reportImageDownloadProgress(chapter, completed = 0)
+        repository.saveCachedImagesAwait(
+            bookSource = bookSource,
+            book = book,
+            chapter = chapter,
+            onProgress = { completed, total ->
+                reportImageDownloadProgress(chapter, completed, total)
+            },
+        )
+    }
+
     suspend fun downloadAwait(chapter: BookChapter): String {
         synchronized(this) {
             onDownloadSet.add(chapter.index)
@@ -639,16 +741,7 @@ class CacheBookModel(
         }
         try {
             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)
-                    },
-                )
-            }
+            ensureChapterImagesCached(chapter)
             onSuccess(chapter)
             ReadBook.downloadedChapters.add(chapter.index)
             ReadBook.downloadFailChapters.remove(chapter.index)
@@ -712,15 +805,9 @@ class CacheBookModel(
             context = IO,
             executeContext = IO,
             semaphore = semaphore
-        ).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)
-            ReadBook.downloadedChapters.add(chapter.index)
-            ReadBook.downloadFailChapters.remove(chapter.index)
+        ).timeout(DOWNLOAD_TIMEOUT_MS)
+        task.onSuccess { content ->
+            // 正文先交给阅读器;缓存成功态等图片完成后再标记
             downloadFinish(
                 chapter,
                 content,
@@ -728,6 +815,19 @@ class CacheBookModel(
                 preserveReadAloudPosition = preserveReadAloudPosition,
             )
             emitPendingReadContent(chapter, content)
+            try {
+                ensureChapterImagesCached(chapter)
+                onSuccess(chapter)
+                ReadBook.downloadedChapters.add(chapter.index)
+                ReadBook.downloadFailChapters.remove(chapter.index)
+            } catch (e: CancellationException) {
+                throw e
+            } catch (e: Exception) {
+                onError(chapter, e)
+                ReadBook.downloadFailChapters[chapter.index] =
+                    (ReadBook.downloadFailChapters[chapter.index] ?: 0) + 1
+                // 不覆盖已展示的正文
+            }
         }.onError {
             onError(chapter, it)
             ReadBook.downloadFailChapters[chapter.index] =
@@ -749,7 +849,9 @@ class CacheBookModel(
                 preserveReadAloudPosition = preserveReadAloudPosition,
             )
         }.onFinally {
-            chapterTasks.remove(chapter.index)
+            if (chapterTasks[chapter.index] === task) {
+                chapterTasks.remove(chapter.index)
+            }
             host.onTaskQueuesChanged(book.bookUrl)
         }
         task.start()

+ 26 - 14
app/src/main/java/io/legado/app/model/ReadManga.kt

@@ -471,18 +471,20 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
         }.start()
     }
 
-    private fun cacheChapterImagesIfNeeded(
+    /**
+     * @return true 表示非图片书,或图片已齐全 / 本次补全成功
+     */
+    private suspend 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()
+    ): Boolean {
+        if (!book.isImage) return true
+        val source = bookSource ?: return false
+        if (BookHelp.hasImageContent(book, chapter)) return true
+        val resolvedContent = content ?: BookHelp.getContent(book, chapter) ?: return false
+        val failures = BookHelp.saveImages(source, book, chapter, resolvedContent)
+        return BookHelp.isChapterImageCacheComplete(book, chapter, failures)
     }
 
     private fun preDownload() {
@@ -531,8 +533,13 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
         val book = book ?: return
         val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, index) ?: return
         if (BookHelp.hasContent(book, chapter)) {
-            downloadedChapters.add(chapter.index)
-            cacheChapterImagesIfNeeded(book, chapter)
+            if (cacheChapterImagesIfNeeded(book, chapter)) {
+                downloadedChapters.add(chapter.index)
+                downloadFailChapters.remove(chapter.index)
+            } else {
+                downloadFailChapters[chapter.index] =
+                    (downloadFailChapters[chapter.index] ?: 0) + 1
+            }
         } else {
             delay(1000)
             if (addLoading(index)) {
@@ -553,10 +560,15 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
         val bookSource = bookSource
         if (bookSource != null) {
             downloadNetworkContent(bookSource, scope, chapter, book, semaphore, success = { content ->
-                cacheChapterImagesIfNeeded(book, chapter, content)
-                downloadedChapters.add(chapter.index)
-                downloadFailChapters.remove(chapter.index)
+                // 正文先展示;「已下载」集合等图片补全后再写入,避免预下载误跳过缺图章节
                 contentLoadFinish(chapter, content)
+                if (cacheChapterImagesIfNeeded(book, chapter, content)) {
+                    downloadedChapters.add(chapter.index)
+                    downloadFailChapters.remove(chapter.index)
+                } else {
+                    downloadFailChapters[chapter.index] =
+                        (downloadFailChapters[chapter.index] ?: 0) + 1
+                }
             }, error = {
                 downloadFailChapters[chapter.index] =
                     (downloadFailChapters[chapter.index] ?: 0) + 1

+ 8 - 0
app/src/main/java/io/legado/app/model/cache/CacheDownloadAdmissionQueue.kt

@@ -45,6 +45,14 @@ class CacheDownloadAdmissionQueue(
         return removed
     }
 
+    /** 取出并清空全部待准入请求(用于全局暂停前落盘为 model)。 */
+    fun drainAll(): List<CacheDownloadRequest> {
+        if (requests.isEmpty()) return emptyList()
+        val drained = requests.toList()
+        requests.clear()
+        return drained
+    }
+
     fun clear() {
         requests.clear()
     }

+ 85 - 10
app/src/main/java/io/legado/app/model/cache/CacheDownloadQueue.kt

@@ -127,7 +127,8 @@ class CacheDownloadQueue {
         while (indices.isNotEmpty()) {
             val index = indices.first()
             indices.remove(index)
-            if (index in runningIndices || removedIndices.contains(index)) continue
+            // indices 为显式排队:即使 range 上有 remove 孔也要出队
+            if (index in runningIndices || emittedIndices.contains(index)) continue
             emittedIndices.add(index)
             return CacheDownloadCandidate(bookUrl, index)
         }
@@ -169,17 +170,36 @@ class CacheDownloadQueue {
     }
 
     fun waitingCount(): Int {
-        val indexCount = indices.count {
-            !emittedIndices.contains(it) && !removedIndices.contains(it)
-        }
+        val indexSet = indices.filterNot { emittedIndices.contains(it) }.toHashSet()
         val rangeCount = if (ranges.size <= 1) {
-            ranges.sumOf { it.remainingCount(emittedIndices, removedIndices) }
+            ranges.sumOf { cursor ->
+                if (cursor.next > cursor.end) 0
+                else {
+                    var count = 0
+                    var i = cursor.next
+                    while (i <= cursor.end) {
+                        if (
+                            !emittedIndices.contains(i) &&
+                            !removedIndices.contains(i) &&
+                            i !in indexSet
+                        ) {
+                            count++
+                        }
+                        i++
+                    }
+                    count
+                }
+            }
         } else {
             val seen = mutableSetOf<Int>()
             ranges.forEach { cursor ->
                 var i = cursor.next
                 while (i <= cursor.end) {
-                    if (!emittedIndices.contains(i) && !removedIndices.contains(i)) {
+                    if (
+                        !emittedIndices.contains(i) &&
+                        !removedIndices.contains(i) &&
+                        i !in indexSet
+                    ) {
                         seen.add(i)
                     }
                     i++
@@ -187,12 +207,61 @@ class CacheDownloadQueue {
             }
             seen.size
         }
-        return indexCount + rangeCount
+        return indexSet.size + rangeCount
     }
 
     fun isWaiting(index: Int): Boolean {
-        if (emittedIndices.contains(index) || removedIndices.contains(index)) return false
-        return indices.contains(index) || ranges.any { it.contains(index) }
+        if (emittedIndices.contains(index)) return false
+        // 显式 indices 优先:即使 range 上打了 remove 孔,仍视为等待
+        if (indices.contains(index)) return true
+        if (removedIndices.contains(index)) return false
+        return ranges.any { it.contains(index) }
+    }
+
+    /**
+     * 当前仍等待下载的章节(indices 在前,其次为 range 剩余),不消费队列。
+     */
+    fun waitingIndices(): List<Int> {
+        val result = linkedSetOf<Int>()
+        indices.forEach { index ->
+            if (!emittedIndices.contains(index)) {
+                result.add(index)
+            }
+        }
+        ranges.forEach { cursor ->
+            var i = cursor.next
+            while (i <= cursor.end) {
+                if (
+                    !emittedIndices.contains(i) &&
+                    !removedIndices.contains(i) &&
+                    i !in result
+                ) {
+                    result.add(i)
+                }
+                i++
+            }
+        }
+        return result.toList()
+    }
+
+    /**
+     * 将章节提到队首(indices 优先于 range),供「点某一章优先下载」使用。
+     */
+    fun prioritize(index: Int) {
+        // 从 range 挖孔,避免与 indices 双重计数;显式 indices 不受 removed 影响
+        if (isInRange(index)) {
+            removedIndices.add(index)
+        }
+        emittedIndices.remove(index)
+        indices.remove(index)
+        val rest = indices.toList()
+        indices.clear()
+        indices.add(index)
+        rest.forEach { indices.add(it) }
+    }
+
+    private fun isInRange(index: Int): Boolean {
+        return ranges.any { it.contains(index) }
     }
 
     private fun addRange(start: Int, end: Int) {
@@ -208,7 +277,13 @@ class CacheDownloadQueue {
 
     private fun addIndex(index: Int) {
         emittedIndices.remove(index)
-        removedIndices.remove(index)
+        // 若该章仍落在某个 range 游标内,保留 removed 孔以免 waitingCount 双计;
+        // 仅当不在 range 内时清除 removed(失败重试等已从 range 消费过的场景)。
+        if (!isInRange(index)) {
+            removedIndices.remove(index)
+        } else {
+            removedIndices.add(index)
+        }
         indices.add(index)
     }
 }

+ 6 - 2
app/src/main/java/io/legado/app/model/cache/CacheDownloadRepository.kt

@@ -4,6 +4,7 @@ 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.NoStackTraceException
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.model.webBook.WebBook
@@ -46,8 +47,11 @@ class CacheDownloadRepository {
         chapter: BookChapter,
         onProgress: (suspend (completed: Int, total: Int) -> Unit)? = null,
     ) {
-        BookHelp.getContent(book, chapter)?.let { content ->
-            BookHelp.saveImages(bookSource, book, chapter, content, 1, onProgress)
+        val content = BookHelp.getContent(book, chapter)
+            ?: throw NoStackTraceException("${book.name} ${chapter.title} 图片缓存未完成:缺少正文")
+        val failures = BookHelp.saveImages(bookSource, book, chapter, content, 1, onProgress)
+        if (!BookHelp.isChapterImageCacheComplete(book, chapter, failures)) {
+            throw NoStackTraceException("${book.name} ${chapter.title} 图片缓存未完成")
         }
     }
 

+ 62 - 0
app/src/main/java/io/legado/app/model/cache/ExplicitCacheBookFifo.kt

@@ -0,0 +1,62 @@
+package io.legado.app.model.cache
+
+/**
+ * 显式离线缓存的书籍级 FIFO 顺序。
+ * 不包含阅读器预下载。
+ * 恢复时:有其它书正在下载则 [moveToTail];全部暂停则 [moveToHead]。
+ */
+class ExplicitCacheBookFifo {
+
+    private val order = ArrayDeque<String>()
+    private val bookUrls = hashSetOf<String>()
+
+    val size: Int
+        get() = order.size
+
+    fun isEmpty(): Boolean = order.isEmpty()
+
+    fun contains(bookUrl: String): Boolean = bookUrl in bookUrls
+
+    fun ensure(bookUrl: String) {
+        if (bookUrls.add(bookUrl)) {
+            order.addLast(bookUrl)
+        }
+    }
+
+    fun remove(bookUrl: String): Boolean {
+        if (!bookUrls.remove(bookUrl)) return false
+        order.remove(bookUrl)
+        return true
+    }
+
+    fun moveToHead(bookUrl: String) {
+        if (bookUrl !in bookUrls) return
+        order.remove(bookUrl)
+        order.addFirst(bookUrl)
+    }
+
+    fun moveToTail(bookUrl: String) {
+        if (bookUrl !in bookUrls) return
+        order.remove(bookUrl)
+        order.addLast(bookUrl)
+    }
+
+    /**
+     * 仅在已持有本实例监视器、且 [predicate] 不再获取其它锁时使用。
+     * 若需结合 CacheBookModel 状态筛选,应先 [snapshot] 再在锁外判断,避免 ABBA 死锁。
+     */
+    fun headWhere(predicate: (String) -> Boolean): String? {
+        return order.firstOrNull(predicate)
+    }
+
+    /** 顺序副本,供调用方在锁外做 model 状态判断。 */
+    fun snapshot(): List<String> = order.toList()
+
+    /** 除 [bookUrl] 外的顺序副本,供恢复时判断其它显式书是否可调度。 */
+    fun urlsBesides(bookUrl: String): List<String> = order.filter { it != bookUrl }
+
+    fun clear() {
+        order.clear()
+        bookUrls.clear()
+    }
+}

+ 62 - 24
app/src/main/java/io/legado/app/service/CacheBookService.kt

@@ -59,7 +59,8 @@ class CacheBookService : BaseService() {
     }
 
     private val threadCount = OtherConfig.cacheBookThreadCount.coerceIn(1, CacheBook.maxDownloadConcurrency)
-    private val maxActiveBookCount = (threadCount * 2).coerceAtLeast(1)
+    // 显式离线缓存:书籍级 FIFO,同时只准入一本;单书内仍用 threadCount 并发章节
+    private val maxActiveBookCount = 1
     private val admissionQueue = CacheDownloadAdmissionQueue(maxActiveBookCount)
     private val admissionLock = Any()
     private val admittingBookUrls = hashSetOf<String>()
@@ -149,6 +150,9 @@ class CacheBookService : BaseService() {
                 IntentAction.pause -> {
                     serviceCommandScope.launch {
                         serviceCommandMutex.withLock {
+                            // 全局暂停前:把准入队列/缓冲里尚未落地的书也建成 model,再统一 pause
+                            // 否则 FAB 暂停只冻住队首,恢复时其余书仍停在「待准入/假暂停」
+                            flushPendingAdmissionsIntoModels()
                             CacheBook.pauseAllFromService()
                             notificationContent = CacheBook.downloadSummary
                             upCacheBookNotification(force = true)
@@ -165,6 +169,16 @@ class CacheBookService : BaseService() {
                         }
                     }
                 }
+                IntentAction.continueDownload -> {
+                    serviceCommandScope.launch {
+                        serviceCommandMutex.withLock {
+                            CacheBook.clearGlobalPauseFromService()
+                            ensureDownloadJob()
+                            notificationContent = CacheBook.downloadSummary
+                            upCacheBookNotification(force = true)
+                        }
+                    }
+                }
             }
         }
         super.onStartCommand(intent, flags, startId)
@@ -214,36 +228,51 @@ class CacheBookService : BaseService() {
         )
     }
 
+    /**
+     * 将准入队列与 admission 缓冲中的请求全部写入 CacheBookModel,并清除 pending 计数。
+     * 供全局暂停使用,保证 FAB 一键暂停覆盖所有已开始下载的书。
+     */
+    private suspend fun flushPendingAdmissionsIntoModels() {
+        val bufferedRequests = synchronized(admissionLock) {
+            admissionGenerations.keys.toList().forEach { bookUrl ->
+                admissionGenerations[bookUrl] = admissionGenerations[bookUrl].orZero() + 1L
+            }
+            val requests = ArrayList<CacheDownloadRequest>()
+            admissionBuffers.values.forEach { buffer ->
+                while (buffer.isNotEmpty()) {
+                    requests.add(buffer.removeFirst().request)
+                }
+            }
+            admissionBuffers.clear()
+            admittingBookUrls.clear()
+            admissionIdleWaiters.values.flatten().forEach { it.complete(Unit) }
+            admissionIdleWaiters.clear()
+            requests
+        }
+        val queuedRequests = synchronized(admissionQueue) {
+            admissionQueue.drainAll()
+        }
+        (bufferedRequests + queuedRequests).forEach { request ->
+            val cacheBook = CacheBook.getOrCreate(request.bookUrl) ?: return@forEach
+            cacheBook.addRequest(request)
+            CacheBook.removePendingAdmission(request)
+        }
+    }
+
     private fun addDownloadRequest(request: CacheDownloadRequest) {
         addDownloadRequestsToQueue(listOf(request))
     }
 
     private fun addDownloadRequestsToQueue(requests: List<CacheDownloadRequest>) {
         if (requests.isEmpty()) return
-        val queuedRequests = mutableListOf<CacheDownloadRequest>()
-        val startRequests = mutableListOf<CacheDownloadRequest>()
-        synchronized(admissionQueue) {
-            // 快照当前已准入的书籍集合;同一批次内同一本书的后续请求直接准入,
-            // 绕过 maxActiveBookCount 限制。并发批次各自持有独立的快照,
-            // 因此准入上限是尽力而为的(best-effort),非严格保证。
-            val activeBookUrls = admittedBookUrls().toMutableSet()
-            requests.forEach { request ->
-                if (!admissionQueue.shouldQueue(request, activeBookUrls)) {
-                    startRequests.add(request)
-                    activeBookUrls.add(request.bookUrl)
-                    return@forEach
-                }
-                admissionQueue.add(request)
-                queuedRequests.add(request)
-            }
-        }
-        if (queuedRequests.isNotEmpty()) {
-            CacheBook.addPendingAdmissions(queuedRequests)
-            ensureDownloadJob()
-        }
-        startRequests.forEach { request ->
+        // 书籍级 FIFO 独占已由 CacheBook.explicitFifo + processJob 保证。
+        // 若再把后续书丢进 admissionQueue,它们只增加 pendingAdmission 计数、不进 Model,
+        // 书级会显示「等待」但章节行仍是「未缓存」,整书暂停也无效。
+        // 因此所有请求立即准入 Model;真正开下仍只调度 FIFO 队首。
+        requests.forEach { request ->
             submitDownloadRequest(request, fromAdmissionQueue = false)
         }
+        ensureDownloadJob()
     }
 
     private fun submitDownloadRequest(
@@ -295,6 +324,12 @@ class CacheBookService : BaseService() {
             return
         }
 
+        // getOrCreate 已写入 taskMap;显式缓存必须先登记 FIFO,再 setLoading/addRequest。
+        // 否则 isLoading/waiting 可见窗口内会被 processJob 当成非 FIFO 并行调度。
+        if (request.source != CacheDownloadSource.ReadPreload) {
+            CacheBook.ensureExplicitFifo(request.bookUrl)
+        }
+
         val book = cacheBook.book
         val chapterCount = appDb.bookChapterDao.getChapterCount(request.bookUrl)
 
@@ -377,7 +412,10 @@ class CacheBookService : BaseService() {
 
     private fun admittedBookUrls(): Set<String> {
         return synchronized(admissionLock) {
-            CacheBook.cacheBookMap.keys.toHashSet().apply {
+            // 已暂停或仅剩暂停章节的书籍不占用 FIFO 名额,便于让位给下一本
+            CacheBook.cacheBookMap.filterValues { model ->
+                model.isLoading() || model.isWaitingRetry() || model.hasRunnableDownloads()
+            }.keys.toHashSet().apply {
                 addAll(admittingBookUrls)
             }
         }

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

@@ -0,0 +1,19 @@
+package io.legado.app.ui.book.cache.manage
+
+/**
+ * 缓存管理书籍列表排序:以显式下载 FIFO 顺序为主,避免按「是否正在下载」排序
+ * 在 FAB 全局启停时因分批状态刷新导致列表跳动。
+ */
+fun sortBookCacheItems(
+    items: List<BookCacheBookItem>,
+    downloadOrder: List<String>,
+): List<BookCacheBookItem> {
+    val orderIndex = downloadOrder.withIndex().associate { (index, bookUrl) -> bookUrl to index }
+    return items.sortedWith(
+        compareBy<BookCacheBookItem> { item ->
+            orderIndex[item.bookUrl] ?: Int.MAX_VALUE
+        }.thenByDescending { it.hasDownloadTask }
+            .thenByDescending { it.cachedCount }
+            .thenBy { it.name },
+    )
+}

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

@@ -608,16 +608,25 @@ private fun BookCacheChapterRow(
 
 @Composable
 private fun chapterStatusText(item: BookCacheChapterItem): String {
-    if (item.isDownloading && !item.progressLabel.isNullOrBlank()) {
-        return item.progressLabel
-    }
-    return when {
-        item.isDownloading -> stringResource(R.string.downloading)
-        item.isWaiting -> stringResource(R.string.wait_download)
-        item.isPaused -> stringResource(R.string.download_paused)
-        item.isError -> stringResource(R.string.download_error)
-        item.isCached -> stringResource(R.string.download_success)
-        else -> stringResource(R.string.not_cached)
+    return when (
+        resolveChapterCacheStatusKey(
+            isDownloading = item.isDownloading,
+            isWaiting = item.isWaiting,
+            isPaused = item.isPaused,
+            isError = item.isError,
+            isCached = item.isCached,
+            hasProgressLabel = !item.progressLabel.isNullOrBlank(),
+        )
+    ) {
+        ChapterCacheStatusKey.ProgressLabel -> item.progressLabel.orEmpty()
+        ChapterCacheStatusKey.Downloading -> stringResource(R.string.downloading)
+        ChapterCacheStatusKey.ErrorWaitingRetry ->
+            stringResource(R.string.download_error_waiting_retry)
+        ChapterCacheStatusKey.Waiting -> stringResource(R.string.wait_download)
+        ChapterCacheStatusKey.Paused -> stringResource(R.string.download_paused)
+        ChapterCacheStatusKey.Error -> stringResource(R.string.download_error)
+        ChapterCacheStatusKey.Cached -> stringResource(R.string.download_success)
+        ChapterCacheStatusKey.NotCached -> stringResource(R.string.not_cached)
     }
 }
 

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

@@ -130,6 +130,7 @@ class BookCacheManageViewModel(
     private var observeJob: Job? = null
     private var fullReloadJob: Job? = null
     private val bookReloadJobs = hashMapOf<String, Job>()
+    private val chapterLoadJobs = hashMapOf<String, Job>()
     private val pendingDownloadRefreshBookUrls = ConcurrentHashMap.newKeySet<String>()
     @Volatile
     private var pendingDownloadSummaryRefresh = false
@@ -179,16 +180,31 @@ class BookCacheManageViewModel(
         }
         viewModelScope.launch {
             CacheBook.cacheSuccessFlow.collect { chapter ->
-                scheduleDownloadStatusRefresh(chapter.bookUrl)
+                // 增量标记已缓存,避免每成功一章就 countImageCachedChapters / 展开列表扫盘
+                markChapterCachedInUi(chapter.bookUrl, chapter.index)
             }
         }
         viewModelScope.launch {
+            var previousBooks = emptyMap<String, CacheBookDownloadState>()
             CacheBook.downloadStateFlow.collect { state ->
+                val prev = previousBooks
+                previousBooks = state.books
                 state.books.forEach { (bookUrl, bookState) ->
-                    if (bookState.runningIndices.isNotEmpty() || bookState.chapterProgress.isNotEmpty()) {
+                    val old = prev[bookUrl]
+                    if (old == bookState) return@forEach
+                    val hasActivity = bookState.runningIndices.isNotEmpty() ||
+                        bookState.chapterProgress.isNotEmpty()
+                    if (hasActivity) {
                         scheduleProgressUpdate(bookUrl, debounceMillis = 100)
                     } else {
-                        scheduleDownloadStatusRefresh(bookUrl)
+                        // 排队/暂停/结束:只刷内存运行时态;isCached 由 cacheSuccessFlow 增量校正
+                        scheduleRuntimeStatusRefresh(bookUrl)
+                    }
+                }
+                // 从 state 移除的书也要刷掉运行时角标
+                prev.keys.forEach { bookUrl ->
+                    if (!state.books.containsKey(bookUrl)) {
+                        scheduleRuntimeStatusRefresh(bookUrl)
                     }
                 }
                 pendingDownloadSummaryRefresh = true
@@ -196,13 +212,13 @@ class BookCacheManageViewModel(
         }
         viewModelScope.launch {
             CacheBook.pendingAdmissionFlow.collect { pending ->
-                pending.keys.forEach { scheduleDownloadStatusRefresh(it) }
+                pending.keys.forEach { scheduleRuntimeStatusRefresh(it) }
                 pendingDownloadSummaryRefresh = true
             }
         }
         viewModelScope.launch {
             CacheBook.queueChangedFlow.collect { bookUrl ->
-                scheduleDownloadStatusRefresh(bookUrl)
+                scheduleRuntimeStatusRefresh(bookUrl)
             }
         }
         viewModelScope.launch {
@@ -258,7 +274,7 @@ class BookCacheManageViewModel(
         }
     }
 
-    private fun scheduleDownloadStatusRefresh(bookUrl: String) {
+    private fun scheduleRuntimeStatusRefresh(bookUrl: String) {
         if (bookUrl.isNotBlank()) {
             pendingDownloadRefreshBookUrls.add(bookUrl)
         }
@@ -270,8 +286,10 @@ class BookCacheManageViewModel(
         bookUrls.forEach { pendingDownloadRefreshBookUrls.remove(it) }
         val shouldRefreshSummary = pendingDownloadSummaryRefresh || bookUrls.isNotEmpty()
         pendingDownloadSummaryRefresh = false
-        bookUrls.forEach { bookUrl ->
-            reloadBook(bookUrl)
+        if (bookUrls.isNotEmpty()) {
+            // 批量轻量刷新:只改内存中的排队/进度态,并对列表按 FIFO 重排
+            updateRuntimeDownloadStatuses(bookUrls)
+            return
         }
         if (shouldRefreshSummary) {
             _uiState.update {
@@ -291,13 +309,74 @@ class BookCacheManageViewModel(
             if (debounceMillis > 0) {
                 delay(debounceMillis)
             }
-            updateDownloadProgressOnly(bookUrl)
+            // 进度刷新不重排列表,避免下载中 LazyColumn 频繁挪动项导致折叠/点击卡顿
+            updateRuntimeDownloadStatuses(listOf(bookUrl), resort = false)
             if (bookReloadJobs[bookUrl] == currentCoroutineContext()[Job]) {
                 bookReloadJobs.remove(bookUrl)
             }
         }
     }
 
+    /** 仅刷新运行时下载态(等待/下载中/暂停/进度),不扫磁盘、不重建章节缓存标记。 */
+    private fun scheduleImmediateRuntimeStatusRefresh(bookUrl: String) {
+        viewModelScope.launch {
+            updateRuntimeDownloadStatuses(listOf(bookUrl), resort = true)
+        }
+    }
+
+    private fun refreshAllRuntimeDownloadStatuses() {
+        val bookUrls = (uiState.value.shelfBooks + uiState.value.notShelfBooks).map { it.bookUrl }
+        updateRuntimeDownloadStatuses(bookUrls, resort = true)
+    }
+
+    private fun markChapterCachedInUi(bookUrl: String, chapterIndex: Int) {
+        if (bookUrl.isBlank()) return
+        _uiState.update { state ->
+            val chapters = state.chaptersByBookUrl[bookUrl]?.map { chapter ->
+                if (chapter.index != chapterIndex) {
+                    chapter
+                } else {
+                    chapter.copy(
+                        isCached = true,
+                        isWaiting = false,
+                        isDownloading = false,
+                        isPaused = false,
+                        isError = false,
+                        downloadProgress = null,
+                        progressLabel = null,
+                    )
+                }
+            }
+            val sortedBooks = sortItems(
+                (state.shelfBooks + state.notShelfBooks).map { item ->
+                    if (item.bookUrl != bookUrl) {
+                        item
+                    } else {
+                        val bookState = CacheBook.downloadStateFlow.value.books[bookUrl]
+                            ?: CacheBookDownloadState(bookUrl)
+                        applyDownloadStateToBookItem(
+                            item.copy(cachedCount = min(item.cachedCount + 1, item.totalCount)),
+                            bookState,
+                            CacheBook.cacheBookMap[bookUrl],
+                        )
+                    }
+                }
+            )
+            state.copy(
+                shelfBooks = sortedBooks.filterNot { it.isNotShelf },
+                notShelfBooks = sortedBooks.filter { it.isNotShelf },
+                chaptersByBookUrl = if (chapters != null) {
+                    state.chaptersByBookUrl + (bookUrl to chapters)
+                } else {
+                    state.chaptersByBookUrl
+                },
+                downloadSummary = buildDownloadSummary(sortedBooks),
+                hasPausedDownloads = CacheBook.hasPausedDownloads,
+                version = state.version + 1,
+            )
+        }
+    }
+
     private fun scheduleBookReload(bookUrl: String, debounceMillis: Long = 80) {
         if (bookUrl.isBlank()) return
         bookReloadJobs.remove(bookUrl)?.cancel()
@@ -481,27 +560,31 @@ class BookCacheManageViewModel(
 
     private fun toggleBookExpanded(bookUrl: String) {
         val shouldExpand = !_uiState.value.expandedBookUrls.contains(bookUrl)
-        _uiState.update { state ->
-            if (shouldExpand) {
-                state.copy(
-                    expandedBookUrls = state.expandedBookUrls + bookUrl,
-                    version = state.version + 1,
-                )
-            } else {
+        if (!shouldExpand) {
+            // 折叠立即生效,并取消进行中的章节列表构建,避免扫盘结果回写抢主线程
+            bookReloadJobs.remove(bookUrl)?.cancel()
+            chapterLoadJobs.remove(bookUrl)?.cancel()
+            _uiState.update { state ->
                 state.copy(
                     expandedBookUrls = state.expandedBookUrls - bookUrl,
                     chaptersByBookUrl = state.chaptersByBookUrl - bookUrl,
                     version = state.version + 1,
                 )
             }
+            return
         }
-        if (shouldExpand) {
-            loadBookChapters(bookUrl)
+        _uiState.update { state ->
+            state.copy(
+                expandedBookUrls = state.expandedBookUrls + bookUrl,
+                version = state.version + 1,
+            )
         }
+        loadBookChapters(bookUrl)
     }
 
     private fun loadBookChapters(bookUrl: String) {
-        viewModelScope.launch {
+        chapterLoadJobs.remove(bookUrl)?.cancel()
+        chapterLoadJobs[bookUrl] = viewModelScope.launch {
             val chapters = withContext(Dispatchers.IO) {
                 buildChapterItems(bookUrl)
             }
@@ -515,6 +598,9 @@ class BookCacheManageViewModel(
                     )
                 }
             }
+            if (chapterLoadJobs[bookUrl] == currentCoroutineContext()[Job]) {
+                chapterLoadJobs.remove(bookUrl)
+            }
         }
     }
 
@@ -522,7 +608,8 @@ class BookCacheManageViewModel(
         execute {
             CacheBook.pause(context)
         }.onFinally {
-            reloadAll(forceDatabase = true)
+            // 勿 forceDatabase 全量重载:展开章列表扫盘会卡住折叠/启停
+            refreshAllRuntimeDownloadStatuses()
         }
     }
 
@@ -530,13 +617,14 @@ class BookCacheManageViewModel(
         execute {
             CacheBook.pauseBook(context, bookUrl)
         }.onFinally {
-            scheduleBookReload(bookUrl, debounceMillis = 0)
+            scheduleImmediateRuntimeStatusRefresh(bookUrl)
         }
     }
 
     private fun startAllDownloads() {
         val items = uiState.value.shelfBooks + uiState.value.notShelfBooks
         execute {
+            // FAB 一键开始:先全局解冻所有已在任务里的书/章;无任务时再为全部缺章书入队
             if (CacheBook.resume(context)) {
                 return@execute null
             }
@@ -558,7 +646,7 @@ class BookCacheManageViewModel(
         }.onError {
             _effects.tryEmit(BookCacheManageEffect.ShowMessage("加入缓存队列失败\n${it.localizedMessage}"))
         }.onFinally {
-            reloadAll(forceDatabase = true)
+            refreshAllRuntimeDownloadStatuses()
         }
     }
 
@@ -582,7 +670,7 @@ class BookCacheManageViewModel(
         }.onError {
             _effects.tryEmit(BookCacheManageEffect.ShowMessage("加入缓存队列失败\n${it.localizedMessage}"))
         }.onFinally {
-            scheduleBookReload(bookUrl, debounceMillis = 0)
+            scheduleImmediateRuntimeStatusRefresh(bookUrl)
         }
     }
 
@@ -635,42 +723,57 @@ class BookCacheManageViewModel(
         }
     }
 
-    private fun updateDownloadProgressOnly(bookUrl: String) {
-        val bookState = CacheBook.downloadStateFlow.value.books[bookUrl] ?: return
-        val model = CacheBook.cacheBookMap[bookUrl]
+    private fun updateRuntimeDownloadStatuses(
+        bookUrls: Collection<String>,
+        resort: Boolean = true,
+    ) {
+        if (bookUrls.isEmpty()) return
+        val targets = bookUrls.filter { it.isNotBlank() }.toSet()
+        if (targets.isEmpty()) return
+        val stateSnapshot = CacheBook.downloadStateFlow.value.books
         _uiState.update { state ->
             fun updateBookItem(item: BookCacheBookItem): BookCacheBookItem {
-                if (item.bookUrl != bookUrl) return item
+                if (item.bookUrl !in targets) return item
+                val bookState = stateSnapshot[item.bookUrl] ?: CacheBookDownloadState(item.bookUrl)
+                val model = CacheBook.cacheBookMap[item.bookUrl]
                 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,
-                )
+
+            var chaptersByBookUrl = state.chaptersByBookUrl
+            targets.forEach { bookUrl ->
+                val existing = chaptersByBookUrl[bookUrl] ?: return@forEach
+                val bookState = stateSnapshot[bookUrl] ?: CacheBookDownloadState(bookUrl)
+                val model = CacheBook.cacheBookMap[bookUrl]
+                val errors = errorIndices(bookUrl)
+                chaptersByBookUrl = chaptersByBookUrl + (bookUrl to existing.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(
+                        // 进行中强制未缓存;完成后由 markChapterCachedInUi 校正 isCached/cachedCount
+                        isCached = if (isDownloading || isWaiting || isPaused) false else item.isCached,
+                        isWaiting = isWaiting,
+                        isDownloading = isDownloading,
+                        isPaused = isPaused,
+                        isError = errors.contains(item.index),
+                        downloadProgress = downloadProgress,
+                        progressLabel = progressLabel,
+                    )
+                })
             }
-            val shelfBooks = state.shelfBooks.map(::updateBookItem)
-            val notShelfBooks = state.notShelfBooks.map(::updateBookItem)
+
+            val updatedBooks = (state.shelfBooks + state.notShelfBooks).map(::updateBookItem)
+            val finalBooks = if (resort) sortItems(updatedBooks) else updatedBooks
             state.copy(
-                shelfBooks = shelfBooks,
-                notShelfBooks = notShelfBooks,
-                chaptersByBookUrl = if (updatedChapters != null) {
-                    state.chaptersByBookUrl + (bookUrl to updatedChapters)
-                } else {
-                    state.chaptersByBookUrl
-                },
-                downloadSummary = buildDownloadSummary(shelfBooks + notShelfBooks),
+                shelfBooks = finalBooks.filterNot { item -> item.isNotShelf },
+                notShelfBooks = finalBooks.filter { item -> item.isNotShelf },
+                chaptersByBookUrl = chaptersByBookUrl,
+                downloadSummary = buildDownloadSummary(finalBooks),
                 hasPausedDownloads = CacheBook.hasPausedDownloads,
                 version = state.version + 1,
             )
@@ -728,7 +831,7 @@ class BookCacheManageViewModel(
         }.onError {
             _effects.tryEmit(BookCacheManageEffect.ShowMessage("章节缓存失败\n${it.localizedMessage}"))
         }.onFinally {
-            scheduleBookReload(bookUrl, debounceMillis = 0)
+            scheduleImmediateRuntimeStatusRefresh(bookUrl)
         }
     }
 
@@ -736,7 +839,7 @@ class BookCacheManageViewModel(
         execute {
             CacheBook.pauseChapter(bookUrl, chapterIndex)
         }.onSuccess {
-            scheduleBookReload(bookUrl, debounceMillis = 0)
+            scheduleImmediateRuntimeStatusRefresh(bookUrl)
         }
     }
 
@@ -767,9 +870,7 @@ class BookCacheManageViewModel(
     }
 
     private fun sortItems(items: List<BookCacheBookItem>): List<BookCacheBookItem> {
-        return items.sortedWith(compareByDescending<BookCacheBookItem> { it.isDownloading }
-            .thenByDescending { it.cachedCount }
-            .thenBy { it.name })
+        return sortBookCacheItems(items, CacheBook.explicitDownloadOrder())
     }
 
     private fun buildDownloadSummary(items: List<BookCacheBookItem>): String {

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

@@ -0,0 +1,34 @@
+package io.legado.app.ui.book.cache.manage
+
+/**
+ * 缓存管理章节行状态文案键(与 strings.xml 对应)。
+ * 纯函数,便于 JVM 单测覆盖优先级。
+ */
+enum class ChapterCacheStatusKey {
+    ProgressLabel,
+    Downloading,
+    ErrorWaitingRetry,
+    Waiting,
+    Paused,
+    Error,
+    Cached,
+    NotCached,
+}
+
+fun resolveChapterCacheStatusKey(
+    isDownloading: Boolean,
+    isWaiting: Boolean,
+    isPaused: Boolean,
+    isError: Boolean,
+    isCached: Boolean,
+    hasProgressLabel: Boolean,
+): ChapterCacheStatusKey {
+    if (isDownloading && hasProgressLabel) return ChapterCacheStatusKey.ProgressLabel
+    if (isDownloading) return ChapterCacheStatusKey.Downloading
+    if (isWaiting && isError) return ChapterCacheStatusKey.ErrorWaitingRetry
+    if (isWaiting) return ChapterCacheStatusKey.Waiting
+    if (isPaused) return ChapterCacheStatusKey.Paused
+    if (isError) return ChapterCacheStatusKey.Error
+    if (isCached) return ChapterCacheStatusKey.Cached
+    return ChapterCacheStatusKey.NotCached
+}

+ 1 - 0
app/src/main/res/values-zh-rCN/strings.xml

@@ -1090,6 +1090,7 @@
     <string name="wait_download">待下载</string>
     <string name="download_success">下载完成</string>
     <string name="download_error">下载失败</string>
+    <string name="download_error_waiting_retry">下载失败,等待重试</string>
     <string name="downloading">下载中</string>
     <string name="unknown_state">未知状态</string>
     <string name="disable_source">禁用源</string>

+ 1 - 0
app/src/main/res/values-zh-rHK/strings.xml

@@ -946,6 +946,7 @@
     <string name="wait_download">待下載</string>
     <string name="download_success">下載完成</string>
     <string name="download_error">下載失敗</string>
+    <string name="download_error_waiting_retry">下載失敗,等待重試</string>
     <string name="downloading">下載中</string>
     <string name="unknown_state">未知狀態</string>
     <string name="disable_source">禁用源</string>

+ 1 - 0
app/src/main/res/values-zh-rTW/strings.xml

@@ -949,6 +949,7 @@
     <string name="wait_download">待下載</string>
     <string name="download_success">下載完成</string>
     <string name="download_error">下載失敗</string>
+    <string name="download_error_waiting_retry">下載失敗,等待重試</string>
     <string name="downloading">下載中</string>
     <string name="unknown_state">未知狀態</string>
     <string name="disable_source">禁用源</string>

+ 1 - 0
app/src/main/res/values/strings.xml

@@ -1133,6 +1133,7 @@
     <string name="wait_download">wait download</string>
     <string name="download_success">download success</string>
     <string name="download_error">download failure</string>
+    <string name="download_error_waiting_retry">failed, waiting to retry</string>
     <string name="downloading">downloading</string>
     <string name="unknown_state">unknown</string>
     <string name="disable_source">disable source</string>

+ 16 - 0
app/src/test/java/io/legado/app/help/book/BookHelpImageCacheCompleteTest.kt

@@ -0,0 +1,16 @@
+package io.legado.app.help.book
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class BookHelpImageCacheCompleteTest {
+
+    @Test
+    fun completeOnlyWhenNoFailuresAndFilesCached() {
+        assertTrue(isChapterImageCacheComplete(failures = 0, filesCached = true))
+        assertFalse(isChapterImageCacheComplete(failures = 1, filesCached = true))
+        assertFalse(isChapterImageCacheComplete(failures = 0, filesCached = false))
+        assertFalse(isChapterImageCacheComplete(failures = 2, filesCached = false))
+    }
+}

+ 183 - 0
app/src/test/java/io/legado/app/model/CacheBookModelResumeTest.kt

@@ -0,0 +1,183 @@
+package io.legado.app.model
+
+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.model.cache.CacheDownloadRequest
+import io.legado.app.model.cache.CacheDownloadStateStore
+import io.legado.app.model.cache.ChapterSelection
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * 复现:整书暂停后点某一章下载,不应解冻失败重试章/其余章,也不应只 toast 不真正可启动。
+ */
+class CacheBookModelResumeTest {
+
+    @Test
+    fun bookPause_movesAllChaptersOutOfScheduleQueue() {
+        val model = newModel()
+        model.addRequest(
+            CacheDownloadRequest(
+                bookUrl = BOOK_URL,
+                selection = ChapterSelection.Range(0, 35),
+            )
+        )
+        assertTrue(model.pause())
+        assertTrue(model.isPaused())
+        assertFalse(model.hasLaunchableChapters())
+        assertEquals(0, model.diagnostics().waitingChapterCount)
+        assertTrue(model.pausedIndices().containsAll((0..35).toList()))
+    }
+
+    @Test
+    fun resumeSingleChapterFromBookPause_onlyThatChapterBecomesLaunchable() {
+        val model = newModel()
+        model.addRequest(
+            CacheDownloadRequest(
+                bookUrl = BOOK_URL,
+                selection = ChapterSelection.Range(0, 35),
+            )
+        )
+        // 失败重试章会进 indices,优先级高于 range
+        model.addRequest(
+            CacheDownloadRequest(
+                bookUrl = BOOK_URL,
+                selection = ChapterSelection.Single(10),
+            )
+        )
+        assertTrue(model.pause())
+        assertTrue(model.isPaused())
+        assertTrue(model.isPaused(13))
+
+        assertTrue(model.resumeDownload(13))
+
+        assertFalse(model.isPaused())
+        assertTrue(model.hasLaunchableChapters())
+        assertFalse(model.isPaused(13))
+        assertTrue(model.isWaiting(13))
+        // 其余章保持单章暂停,避免点 13 却先跑失败的 10
+        assertTrue(model.pausedIndices().contains(10))
+        assertTrue(model.pausedIndices().contains(0))
+        assertTrue(model.pausedIndices().contains(16))
+        assertFalse(model.pausedIndices().contains(13))
+        assertEquals(1, model.diagnostics().waitingChapterCount)
+    }
+
+    @Test
+    fun addRequestWhileBookPaused_doesNotResumeUnrelatedChapters() {
+        val model = newModel()
+        model.addRequest(
+            CacheDownloadRequest(
+                bookUrl = BOOK_URL,
+                selection = ChapterSelection.Range(0, 35),
+            )
+        )
+        assertTrue(model.pause())
+
+        model.addRequest(
+            CacheDownloadRequest(
+                bookUrl = BOOK_URL,
+                selection = ChapterSelection.Single(16),
+            )
+        )
+
+        assertFalse(model.isPaused())
+        assertTrue(model.hasLaunchableChapters())
+        assertTrue(model.isWaiting(16))
+        assertTrue(model.pausedIndices().contains(10))
+        assertFalse(model.pausedIndices().contains(16))
+        assertEquals(1, model.diagnostics().waitingChapterCount)
+    }
+
+    @Test
+    fun resumeBook_restoresAllPausedChaptersOnlyForThatModel() {
+        val model = newModel()
+        model.addRequest(
+            CacheDownloadRequest(
+                bookUrl = BOOK_URL,
+                selection = ChapterSelection.Range(0, 5),
+            )
+        )
+        assertTrue(model.pause())
+        assertTrue(model.pausedIndices().containsAll((0..5).toList()))
+
+        assertTrue(model.resume())
+        assertFalse(model.isPaused())
+        assertTrue(model.pausedIndices().isEmpty())
+        assertEquals(6, model.diagnostics().waitingChapterCount)
+        assertTrue(model.hasLaunchableChapters())
+    }
+
+    @Test
+    fun resumeAllModels_unpausesEveryBookIndependently() {
+        val host = FakeHost()
+        val a = newModel(host, "https://example.com/a")
+        val b = newModel(host, "https://example.com/b")
+        val c = newModel(host, "https://example.com/c")
+        a.addRequest(CacheDownloadRequest(a.book.bookUrl, ChapterSelection.Range(0, 2)))
+        b.addRequest(CacheDownloadRequest(b.book.bookUrl, ChapterSelection.Range(0, 2)))
+        c.addRequest(CacheDownloadRequest(c.book.bookUrl, ChapterSelection.Range(0, 2)))
+        assertTrue(a.pause())
+        assertTrue(b.pause())
+        assertTrue(c.pause())
+
+        // 模拟 FAB 全局恢复:对 map 内每一本 resume
+        host.cacheBookMap.values.forEach { it.resume() }
+
+        assertTrue(a.hasLaunchableChapters())
+        assertTrue(b.hasLaunchableChapters())
+        assertTrue(c.hasLaunchableChapters())
+        assertFalse(a.isPaused())
+        assertFalse(b.isPaused())
+        assertFalse(c.isPaused())
+    }
+
+    @Test
+    fun resumeDownload_returnsTrueWhenChapterOnlyBookPaused() {
+        val model = newModel()
+        model.addRequest(
+            CacheDownloadRequest(
+                bookUrl = BOOK_URL,
+                selection = ChapterSelection.Range(0, 5),
+            )
+        )
+        assertTrue(model.pause())
+        assertTrue(model.isPaused(3))
+        assertTrue(model.resumeDownload(3))
+        assertTrue(model.hasLaunchableChapters())
+    }
+
+    private fun newModel(
+        host: FakeHost = FakeHost(),
+        bookUrl: String = BOOK_URL,
+    ): CacheBookModel {
+        val model = CacheBookModel(
+            bookSource = BookSource(bookSourceUrl = "https://example.com", bookSourceName = "t"),
+            book = Book(bookUrl = bookUrl, name = "漫画", origin = "https://example.com"),
+            host = host,
+        )
+        host.cacheBookMap[bookUrl] = model
+        return model
+    }
+
+    private class FakeHost : CacheBookModel.Host {
+        override val stateStore = CacheDownloadStateStore()
+        override val cacheBookMap = ConcurrentHashMap<String, CacheBookModel>()
+        override fun incrementSuccessCount(): Int = 0
+        override fun onTaskQueuesChanged(bookUrl: String) = Unit
+        override fun onTaskRemoved(bookUrl: String, clearState: Boolean) = Unit
+        override fun onExplicitBookQueued(bookUrl: String) = Unit
+        override fun emitDownloadingIndices(bookUrl: String, indices: Set<Int>) = Unit
+        override fun emitDownloadError(bookUrl: String, indices: Set<Int>) = Unit
+        override fun emitChapterCached(chapter: BookChapter) = Unit
+        override fun errorIndices(bookUrl: String): Set<Int> = emptySet()
+    }
+
+    companion object {
+        private const val BOOK_URL = "https://example.com/book/1"
+    }
+}

+ 29 - 0
app/src/test/java/io/legado/app/model/cache/CacheDownloadAdmissionQueueTest.kt

@@ -21,6 +21,20 @@ class CacheDownloadAdmissionQueueTest {
         assertEquals(request, queue.pollReady(setOf("a")))
     }
 
+    @Test
+    fun fifoExclusiveOnlyOneNewBookWhenMaxActiveIsOne() {
+        val queue = CacheDownloadAdmissionQueue(maxActiveBooks = 1)
+        assertTrue(queue.shouldQueue(request("b"), setOf("a")))
+        assertFalse(queue.shouldQueue(request("a"), setOf("a")))
+
+        queue.add(request("b"))
+        queue.add(request("c"))
+
+        assertNull(queue.pollReady(setOf("a")))
+        assertEquals(request("b"), queue.pollReady(emptySet()))
+        assertEquals(request("c"), queue.pollReady(emptySet()))
+    }
+
     @Test
     fun activeBookRequestCanBypassLimit() {
         val queue = CacheDownloadAdmissionQueue(maxActiveBooks = 1)
@@ -46,6 +60,21 @@ class CacheDownloadAdmissionQueueTest {
         assertNull(queue.pollReady(emptySet()))
     }
 
+    @Test
+    fun drainAllReturnsFifoOrderAndClearsQueue() {
+        val queue = CacheDownloadAdmissionQueue(maxActiveBooks = 1)
+        val a = request("a")
+        val b = request("b")
+        val c = request("c")
+        queue.add(a)
+        queue.add(b)
+        queue.add(c)
+
+        assertEquals(listOf(a, b, c), queue.drainAll())
+        assertTrue(queue.isEmpty())
+        assertEquals(emptyList<CacheDownloadRequest>(), queue.drainAll())
+    }
+
     private fun request(bookUrl: String): CacheDownloadRequest {
         return CacheDownloadRequest(
             bookUrl = bookUrl,

+ 22 - 0
app/src/test/java/io/legado/app/model/cache/CacheDownloadQueueTest.kt

@@ -67,6 +67,28 @@ class CacheDownloadQueueTest {
         assertEquals(2, queue.next("book", emptySet())?.chapterIndex)
     }
 
+    @Test
+    fun prioritizeMovesChapterAheadOfFailedRetry() {
+        val queue = CacheDownloadQueue()
+        queue.enqueue(ChapterSelection.Range(0, 20))
+        queue.enqueue(ChapterSelection.Single(10))
+        queue.prioritize(13)
+
+        assertEquals(13, queue.next("book", emptySet())?.chapterIndex)
+        assertEquals(10, queue.next("book", emptySet())?.chapterIndex)
+        assertEquals(0, queue.next("book", emptySet())?.chapterIndex)
+    }
+
+    @Test
+    fun waitingIndicesListsIndicesBeforeRangeRemainder() {
+        val queue = CacheDownloadQueue()
+        queue.enqueue(ChapterSelection.Range(0, 3))
+        queue.enqueue(ChapterSelection.Single(9))
+
+        assertEquals(listOf(9, 0, 1, 2, 3), queue.waitingIndices())
+        assertEquals(5, queue.waitingCount())
+    }
+
     private fun drain(queue: CacheDownloadQueue): List<Int> {
         val result = mutableListOf<Int>()
         while (true) {

+ 169 - 0
app/src/test/java/io/legado/app/model/cache/ExplicitCacheBookFifoTest.kt

@@ -0,0 +1,169 @@
+package io.legado.app.model.cache
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ExplicitCacheBookFifoTest {
+
+    @Test
+    fun ensureAppendsNewBooksInOrder() {
+        val fifo = ExplicitCacheBookFifo()
+        fifo.ensure("a")
+        fifo.ensure("b")
+        fifo.ensure("a")
+
+        assertEquals(listOf("a", "b"), fifo.snapshot())
+    }
+
+    @Test
+    fun headWhereSkipsPausedBooks() {
+        val fifo = ExplicitCacheBookFifo()
+        fifo.ensure("a")
+        fifo.ensure("b")
+        fifo.ensure("c")
+
+        val paused = setOf("a")
+        assertEquals("b", fifo.headWhere { it !in paused })
+    }
+
+    @Test
+    fun moveToTailWhenOtherBookDownloading() {
+        val fifo = ExplicitCacheBookFifo()
+        fifo.ensure("a")
+        fifo.ensure("b")
+        fifo.ensure("c")
+
+        fifo.moveToTail("a")
+
+        assertEquals(listOf("b", "c", "a"), fifo.snapshot())
+        assertEquals("b", fifo.headWhere { true })
+    }
+
+    @Test
+    fun moveToHeadWhenAllPaused() {
+        val fifo = ExplicitCacheBookFifo()
+        fifo.ensure("a")
+        fifo.ensure("b")
+        fifo.ensure("c")
+
+        fifo.moveToHead("b")
+
+        assertEquals(listOf("b", "a", "c"), fifo.snapshot())
+        assertEquals("b", fifo.headWhere { true })
+    }
+
+    @Test
+    fun urlsBesidesExcludesSelfKeepsOrder() {
+        val fifo = ExplicitCacheBookFifo()
+        fifo.ensure("a")
+        fifo.ensure("b")
+        fifo.ensure("c")
+
+        assertEquals(listOf("a", "c"), fifo.urlsBesides("b"))
+        // 预下载等不在 FIFO 内的书本来就不会出现,恢复判定只应扫这些 URL
+        assertEquals(listOf("a", "b", "c"), fifo.urlsBesides("preload-only"))
+    }
+
+    @Test
+    fun exclusiveHeadKeepsInFlightBookEvenIfNotLaunchable() {
+        // 与 CacheBook.startProcessJob 一致:选队首看「仍占槽」,不是「还能新启动章节」
+        val order = listOf("a", "b", "c")
+        val occupiesSlot = setOf("a") // a 正在下图,waiting 暂时为空
+        val launchable = setOf("b")
+
+        val exclusiveHead = order.firstOrNull { it in occupiesSlot }
+        val wrongHeadByLaunchable = order.firstOrNull { it in launchable }
+
+        assertEquals("a", exclusiveHead)
+        assertEquals("b", wrongHeadByLaunchable)
+    }
+
+    @Test
+    fun ensureBeforeLaunchablePreventsNonFifoParallel() {
+        // 冷启动并行准入:若先有 launchable、后 ensure,未入 FIFO 的书会被当成预下载并行
+        val fifo = ExplicitCacheBookFifo()
+        val taskMapLaunchable = linkedSetOf<String>()
+
+        // 正确顺序:先 ensure 再暴露可调度
+        fifo.ensure("a")
+        taskMapLaunchable.add("a")
+        fifo.ensure("b")
+        taskMapLaunchable.add("b")
+
+        val explicit = fifo.snapshot().toHashSet()
+        val leakedAsNonFifo = taskMapLaunchable.filterNot { it in explicit }
+        assertTrue(leakedAsNonFifo.isEmpty())
+
+        // 错误顺序(旧逻辑)会泄漏
+        val lateFifo = ExplicitCacheBookFifo()
+        val leaked = linkedSetOf("b", "c")
+        lateFifo.ensure("a")
+        val lateExplicit = lateFifo.snapshot().toHashSet()
+        assertEquals(listOf("b", "c"), leaked.filterNot { it in lateExplicit })
+    }
+
+    @Test
+    fun liveExplicitSetPreventsStaleNonFifoLeakWithoutBlockingPredownload() {
+        // processJob 若用「本轮初 snapshot」排除非 FIFO,而 ensure 发生在 snapshot 之后,
+        // 新书不在旧 set 里 → 与队首抢并发(冷启动第一次 FAB:7+1)。
+        val fifo = ExplicitCacheBookFifo()
+        val staleSnapshot = fifo.snapshot() // 当时为空
+        fifo.ensure("a")
+        fifo.ensure("b")
+        fifo.ensure("c")
+
+        val staleExclude = staleSnapshot.toHashSet()
+        val taskMap = listOf("a", "b", "c", "preload")
+        val leakedByStaleExclude = taskMap.filterNot { it in staleExclude }
+        assertEquals(listOf("a", "b", "c", "preload"), leakedByStaleExclude)
+
+        // 正确策略:用 live snapshot 排除显式书;预下载仍可并行
+        // (勿用 fifo.isEmpty() 整段关闭——会饿死 ReadPreload)
+        val liveExplicit = fifo.snapshot().toHashSet()
+        val nonFifoParallel = taskMap.filterNot { it in liveExplicit }
+        assertEquals(listOf("preload"), nonFifoParallel)
+        assertFalse(fifo.isEmpty())
+    }
+
+    @Test
+    fun resumeToTailOnlyWhenOtherHasInFlightNotMereWaiting() {
+        // 与 CacheBook.hasActiveExplicitDownloadBesides 一致:
+        // 占槽(进行中/加载/重试)→ 队尾;仅 waiting → 仍可提到队首
+        fun shouldMoveToTail(otherHasInFlight: Boolean) = otherHasInFlight
+        val othersWaitingOnly = false // 后续书 waiting,但无人占槽
+        val othersInFlight = true
+        assertFalse(shouldMoveToTail(othersWaitingOnly))
+        assertTrue(shouldMoveToTail(othersInFlight))
+    }
+
+    @Test
+    fun removeDropsBookFromOrder() {
+        val fifo = ExplicitCacheBookFifo()
+        fifo.ensure("a")
+        fifo.ensure("b")
+
+        assertTrue(fifo.remove("a"))
+        assertFalse(fifo.remove("a"))
+        assertEquals(listOf("b"), fifo.snapshot())
+        assertNull(fifo.headWhere { it == "a" })
+    }
+
+    @Test
+    fun snapshotThenFilterOutsideAvoidsNestedModelLock() {
+        // 与 CacheBook.startProcessJob 相同:先 snapshot,再在锁外按状态选队首
+        val fifo = ExplicitCacheBookFifo()
+        fifo.ensure("paused")
+        fifo.ensure("ready")
+        fifo.ensure("waiting")
+
+        val order = fifo.snapshot()
+        val launchable = setOf("ready", "waiting")
+        val head = order.firstOrNull { it in launchable }
+
+        assertEquals("ready", head)
+        assertEquals(listOf("paused", "ready", "waiting"), order)
+    }
+}

+ 71 - 0
app/src/test/java/io/legado/app/ui/book/cache/manage/BookCacheListSortTest.kt

@@ -0,0 +1,71 @@
+package io.legado.app.ui.book.cache.manage
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class BookCacheListSortTest {
+
+    @Test
+    fun keepsFifoOrderEvenWhenOnlyOneBookIsDownloading() {
+        val items = listOf(
+            item("a", downloadingCount = 0, waitingCount = 3),
+            item("b", downloadingCount = 2, waitingCount = 0),
+            item("c", downloadingCount = 0, waitingCount = 0, pausedCount = 5),
+        )
+
+        val sorted = sortBookCacheItems(items, downloadOrder = listOf("a", "b", "c"))
+
+        assertEquals(listOf("a", "b", "c"), sorted.map { it.bookUrl })
+    }
+
+    @Test
+    fun doesNotJumpWhenMiddleBookBecomesActiveFirst() {
+        // 模拟 FAB 恢复时 b 先刷新成下载中、a 仍显示暂停的中间态
+        val midFlush = listOf(
+            item("a", pausedCount = 10),
+            item("b", downloadingCount = 1),
+            item("c", pausedCount = 8),
+        )
+
+        val sorted = sortBookCacheItems(midFlush, downloadOrder = listOf("a", "b", "c"))
+
+        assertEquals(listOf("a", "b", "c"), sorted.map { it.bookUrl })
+    }
+
+    @Test
+    fun booksOutsideFifoStayAfterQueueOrderedByCachedCountThenName() {
+        val items = listOf(
+            item("zoo", cachedCount = 9),
+            item("b", waitingCount = 1),
+            item("alpha", cachedCount = 1),
+            item("a", downloadingCount = 1),
+        )
+
+        val sorted = sortBookCacheItems(items, downloadOrder = listOf("a", "b"))
+
+        assertEquals(listOf("a", "b", "zoo", "alpha"), sorted.map { it.bookUrl })
+    }
+
+    private fun item(
+        bookUrl: String,
+        downloadingCount: Int = 0,
+        waitingCount: Int = 0,
+        pausedCount: Int = 0,
+        cachedCount: Int = 0,
+    ): BookCacheBookItem {
+        return BookCacheBookItem(
+            bookUrl = bookUrl,
+            name = bookUrl,
+            author = "",
+            totalCount = 10,
+            cachedCount = cachedCount,
+            cachedFileCount = cachedCount,
+            waitingCount = waitingCount,
+            downloadingCount = downloadingCount,
+            pausedCount = pausedCount,
+            errorCount = 0,
+            isNotShelf = false,
+            group = 0L,
+        )
+    }
+}

+ 52 - 0
app/src/test/java/io/legado/app/ui/book/cache/manage/ChapterCacheStatusTest.kt

@@ -0,0 +1,52 @@
+package io.legado.app.ui.book.cache.manage
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ChapterCacheStatusTest {
+
+    @Test
+    fun failedRetryShowsErrorWaitingNotPlainWaiting() {
+        assertEquals(
+            ChapterCacheStatusKey.ErrorWaitingRetry,
+            resolveChapterCacheStatusKey(
+                isDownloading = false,
+                isWaiting = true,
+                isPaused = false,
+                isError = true,
+                isCached = false,
+                hasProgressLabel = false,
+            ),
+        )
+    }
+
+    @Test
+    fun progressLabelWinsWhileDownloading() {
+        assertEquals(
+            ChapterCacheStatusKey.ProgressLabel,
+            resolveChapterCacheStatusKey(
+                isDownloading = true,
+                isWaiting = false,
+                isPaused = false,
+                isError = true,
+                isCached = false,
+                hasProgressLabel = true,
+            ),
+        )
+    }
+
+    @Test
+    fun plainWaitingWhenNotError() {
+        assertEquals(
+            ChapterCacheStatusKey.Waiting,
+            resolveChapterCacheStatusKey(
+                isDownloading = false,
+                isWaiting = true,
+                isPaused = false,
+                isError = false,
+                isCached = false,
+                hasProgressLabel = false,
+            ),
+        )
+    }
+}