[优化] 继续干下载逻辑,现在小米设备应该大概可能不会再有主线程锁等待导致的ANR

This commit is contained in:
HapeLee
2026-04-30 02:08:24 +08:00
parent bc670629b4
commit bab3faedef
17 changed files with 1669 additions and 582 deletions
@@ -11,6 +11,7 @@ import io.legado.app.constant.BookType
import io.legado.app.data.entities.Book import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookGroup import io.legado.app.data.entities.BookGroup
import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSource
import io.legado.app.domain.model.CacheableBook
import io.legado.app.help.book.isNotShelf import io.legado.app.help.book.isNotShelf
import io.legado.app.ui.main.bookshelf.BookShelfItem import io.legado.app.ui.main.bookshelf.BookShelfItem
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -516,6 +517,20 @@ interface BookDao {
@Query("SELECT * FROM books WHERE bookUrl = :bookUrl") @Query("SELECT * FROM books WHERE bookUrl = :bookUrl")
fun getBook(bookUrl: String): Book? fun getBook(bookUrl: String): Book?
@Query(
"""
SELECT
bookUrl,
type & ${BookType.local} > 0 AS isLocal,
type & ${BookType.audio} > 0 AS isAudio,
durChapterIndex,
totalChapterNum - 1 AS lastChapterIndex
FROM books
WHERE bookUrl IN (:bookUrls)
"""
)
fun getCacheableBooks(bookUrls: Set<String>): List<CacheableBook>
@Query("SELECT * FROM books WHERE bookUrl = :bookUrl") @Query("SELECT * FROM books WHERE bookUrl = :bookUrl")
fun flowGetBook(bookUrl: String): Flow<Book?> fun flowGetBook(bookUrl: String): Flow<Book?>
@@ -7,7 +7,6 @@ import io.legado.app.domain.model.BookGroupAssignment
import io.legado.app.domain.model.CacheableBook import io.legado.app.domain.model.CacheableBook
import io.legado.app.domain.model.DeletableBook import io.legado.app.domain.model.DeletableBook
import io.legado.app.domain.repository.BookDomainRepository import io.legado.app.domain.repository.BookDomainRepository
import io.legado.app.help.book.isAudio
import io.legado.app.help.book.isLocal import io.legado.app.help.book.isLocal
class BookDomainRepositoryImpl( class BookDomainRepositoryImpl(
@@ -21,15 +20,8 @@ class BookDomainRepositoryImpl(
} }
override suspend fun getCacheableBooks(bookUrls: Set<String>): List<CacheableBook> { override suspend fun getCacheableBooks(bookUrls: Set<String>): List<CacheableBook> {
return getBooks(bookUrls).map { book -> if (bookUrls.isEmpty()) return emptyList()
CacheableBook( return bookDao.getCacheableBooks(bookUrls)
bookUrl = book.bookUrl,
isLocal = book.isLocal,
isAudio = book.isAudio,
durChapterIndex = book.durChapterIndex,
lastChapterIndex = book.lastChapterIndex
)
}
} }
override suspend fun getDeletableBooks(bookUrls: Set<String>): List<DeletableBook> { override suspend fun getDeletableBooks(bookUrls: Set<String>): List<DeletableBook> {
@@ -17,6 +17,11 @@ class CacheBookDownloadRepository(
CacheBook.start(appCtx, request, isLocal = book.isLocal) CacheBook.start(appCtx, request, isLocal = book.isLocal)
} }
override suspend fun start(requests: List<CacheDownloadRequest>) {
if (requests.isEmpty()) return
CacheBook.start(appCtx, requests)
}
override suspend fun start(bookUrl: String, chapterIndices: List<Int>) { override suspend fun start(bookUrl: String, chapterIndices: List<Int>) {
start( start(
CacheDownloadRequest( CacheDownloadRequest(
@@ -4,6 +4,7 @@ import io.legado.app.model.cache.CacheDownloadRequest
interface BookCacheDownloadGateway { interface BookCacheDownloadGateway {
suspend fun start(request: CacheDownloadRequest) suspend fun start(request: CacheDownloadRequest)
suspend fun start(requests: List<CacheDownloadRequest>)
suspend fun start(bookUrl: String, chapterIndices: List<Int>) suspend fun start(bookUrl: String, chapterIndices: List<Int>)
suspend fun start(bookUrl: String, startIndex: Int, endIndex: Int) suspend fun start(bookUrl: String, startIndex: Int, endIndex: Int)
} }
@@ -18,32 +18,27 @@ class BatchCacheDownloadUseCase(
skipAudioBooks: Boolean = false skipAudioBooks: Boolean = false
): Int { ): Int {
if (bookUrls.isEmpty()) return 0 if (bookUrls.isEmpty()) return 0
var count = 0 val requests = bookRepository.getCacheableBooks(bookUrls).mapNotNull { book ->
bookRepository.getCacheableBooks(bookUrls).forEach { book -> createRequestIfNeeded(book, downloadAllChapters, skipAudioBooks)
if (startIfNeeded(book, downloadAllChapters, skipAudioBooks)) {
count++
}
} }
return count bookCacheDownloadGateway.start(requests)
return requests.size
} }
private suspend fun startIfNeeded( private fun createRequestIfNeeded(
book: CacheableBook, book: CacheableBook,
downloadAllChapters: Boolean, downloadAllChapters: Boolean,
skipAudioBooks: Boolean skipAudioBooks: Boolean
): Boolean { ): CacheDownloadRequest? {
if (book.isLocal) return false if (book.isLocal) return null
if (skipAudioBooks && book.isAudio) return false if (skipAudioBooks && book.isAudio) return null
val startIndex = if (downloadAllChapters) 0 else book.durChapterIndex val startIndex = if (downloadAllChapters) 0 else book.durChapterIndex
val endIndex = book.lastChapterIndex val endIndex = book.lastChapterIndex
if (endIndex < startIndex) return false if (endIndex < startIndex) return null
bookCacheDownloadGateway.start( return CacheDownloadRequest(
CacheDownloadRequest( bookUrl = book.bookUrl,
bookUrl = book.bookUrl, selection = ChapterSelection.Range(startIndex, endIndex),
selection = ChapterSelection.Range(startIndex, endIndex), source = CacheDownloadSource.Batch,
source = CacheDownloadSource.Batch,
)
) )
return true
} }
} }
+344 -430
View File
@@ -1,32 +1,21 @@
package io.legado.app.model package io.legado.app.model
import android.content.Context import android.content.Context
import io.legado.app.constant.AppLog
import io.legado.app.constant.IntentAction import io.legado.app.constant.IntentAction
import io.legado.app.data.appDb import io.legado.app.data.appDb
import io.legado.app.data.entities.Book import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSource
import io.legado.app.exception.ConcurrentException
import io.legado.app.help.book.isLocal import io.legado.app.help.book.isLocal
import io.legado.app.help.coroutine.CompositeCoroutine
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.model.cache.CacheDownloadQueue
import io.legado.app.model.cache.CacheDownloadRepository
import io.legado.app.model.cache.CacheDownloadRequest import io.legado.app.model.cache.CacheDownloadRequest
import io.legado.app.model.cache.CacheDownloadSource
import io.legado.app.model.cache.CacheDownloadStateStore import io.legado.app.model.cache.CacheDownloadStateStore
import io.legado.app.model.cache.ChapterSelection import io.legado.app.model.cache.ChapterSelection
import io.legado.app.model.cache.ReadingCacheEvent
import io.legado.app.model.cache.ReadingCacheEvents
import io.legado.app.service.CacheBookService import io.legado.app.service.CacheBookService
import io.legado.app.ui.config.otherConfig.OtherConfig import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.utils.onEachParallel import io.legado.app.utils.onEachParallel
import io.legado.app.utils.startService import io.legado.app.utils.startService
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -39,11 +28,13 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
@@ -51,16 +42,20 @@ object CacheBook {
const val maxDownloadConcurrency = 8 const val maxDownloadConcurrency = 8
data class Diagnostics(
val activeBookCount: Int,
val waitingChapterCount: Int,
val runningChapterCount: Int,
val trackedChapterTaskCount: Int,
val loadingBookCount: Int,
val retryingBookCount: Int,
)
private data class QueueStats( private data class QueueStats(
val waitingCount: Int, val waitingCount: Int,
val downloadingCount: Int val downloadingCount: Int
) )
private data class ChapterKey(
val bookUrl: String,
val index: Int,
)
private class CacheBookCoordinator { private class CacheBookCoordinator {
val taskMap = ConcurrentHashMap<String, CacheBookModel>() val taskMap = ConcurrentHashMap<String, CacheBookModel>()
private val processMutex = Mutex() private val processMutex = Mutex()
@@ -73,13 +68,13 @@ object CacheBook {
suspend fun startProcessJob(context: CoroutineContext) = processMutex.withLock { suspend fun startProcessJob(context: CoroutineContext) = processMutex.withLock {
setWorkingState(true) setWorkingState(true)
flow { flow {
while (currentCoroutineContext().isActive && taskMap.isNotEmpty()) { while (currentCoroutineContext().isActive && taskMap.isNotEmpty() && !isPaused) {
if (!workingState.value) { if (!workingState.value) {
workingState.first { it } workingState.first { it }
} }
var emitted = false var emitted = false
taskMap.forEach { (_, model) -> taskMap.forEach { (_, model) ->
if (!model.isLoading()) { if (model.hasRunnableDownloads()) {
emit(model) emit(model)
emitted = true emitted = true
} }
@@ -98,10 +93,13 @@ object CacheBook {
} }
} }
private val modelHost = ModelHostImpl()
private val coordinator = CacheBookCoordinator() private val coordinator = CacheBookCoordinator()
private val stateStore = CacheDownloadStateStore() private val stateStore = CacheDownloadStateStore()
private val pendingRequests = ConcurrentHashMap<Long, CacheDownloadRequest>() private val pendingRemoveRequests = ConcurrentHashMap<Long, CompletableDeferred<Boolean>>()
private val pendingRequestId = AtomicLong(0) private val pendingRequestId = AtomicLong(0)
@Volatile
private var isPaused = false
val downloadStateFlow = stateStore.stateFlow val downloadStateFlow = stateStore.stateFlow
private val _cacheSuccessFlow = MutableSharedFlow<BookChapter>(extraBufferCapacity = 64) private val _cacheSuccessFlow = MutableSharedFlow<BookChapter>(extraBufferCapacity = 64)
@@ -110,6 +108,9 @@ object CacheBook {
private val _downloadSummaryFlow = MutableStateFlow("") private val _downloadSummaryFlow = MutableStateFlow("")
val downloadSummaryFlow = _downloadSummaryFlow.asStateFlow() val downloadSummaryFlow = _downloadSummaryFlow.asStateFlow()
private val _pendingAdmissionFlow = MutableStateFlow<Map<String, Int>>(emptyMap())
val pendingAdmissionFlow = _pendingAdmissionFlow.asStateFlow()
private val _downloadingIndicesFlow = private val _downloadingIndicesFlow =
MutableStateFlow<Pair<String, Set<Int>>>("" to emptySet()) MutableStateFlow<Pair<String, Set<Int>>>("" to emptySet())
val downloadingIndicesFlow = _downloadingIndicesFlow.asStateFlow() val downloadingIndicesFlow = _downloadingIndicesFlow.asStateFlow()
@@ -123,9 +124,7 @@ object CacheBook {
@Volatile @Volatile
private var lastQueueStats = QueueStats(0, 0) private var lastQueueStats = QueueStats(0, 0)
@Volatile private val successDownloadCount = AtomicInteger(0)
private var successDownloadCount = 0
private val errorRetryMap = ConcurrentHashMap<ChapterKey, Int>()
val cacheBookMap: ConcurrentHashMap<String, CacheBookModel> val cacheBookMap: ConcurrentHashMap<String, CacheBookModel>
get() = coordinator.taskMap get() = coordinator.taskMap
@@ -135,27 +134,48 @@ object CacheBook {
} }
fun markBookFailed(bookUrl: String, message: String) { fun markBookFailed(bookUrl: String, message: String) {
removePendingAdmission(bookUrl)
stateStore.markBookFailed(bookUrl, message) stateStore.markBookFailed(bookUrl, message)
updateSummary() updateSummary()
_queueChangedFlow.tryEmit(bookUrl) _queueChangedFlow.tryEmit(bookUrl)
} }
private fun collectQueueStats(): QueueStats { fun diagnostics(): Diagnostics {
var waiting = 0 var waiting = 0
var downloading = 0 var running = 0
var trackedTasks = 0
var loading = 0
var retrying = 0
cacheBookMap.forEach { (_, model) -> cacheBookMap.forEach { (_, model) ->
val (w, d) = model.queueCounts() val item = model.diagnostics()
waiting += w waiting += item.waitingChapterCount
downloading += d running += item.runningChapterCount
trackedTasks += item.trackedChapterTaskCount
if (item.isLoading) loading++
if (item.waitingRetry) retrying++
} }
return QueueStats(waiting, downloading) return Diagnostics(
activeBookCount = cacheBookMap.size,
waitingChapterCount = waiting,
runningChapterCount = running,
trackedChapterTaskCount = trackedTasks,
loadingBookCount = loading,
retryingBookCount = retrying,
)
}
private fun collectQueueStats(): QueueStats {
val state = stateStore.state
return QueueStats(
waitingCount = state.totalWaiting + _pendingAdmissionFlow.value.values.sum(),
downloadingCount = state.totalRunning,
)
} }
private fun updateSummary() { private fun updateSummary() {
val stats = collectQueueStats() val stats = collectQueueStats()
lastQueueStats = stats lastQueueStats = stats
_downloadSummaryFlow.value = _downloadSummaryFlow.value = buildSummary(stats)
"正在下载:${stats.downloadingCount}|等待中:${stats.waitingCount}|失败:${stateStore.state.totalFailure}|成功:$successDownloadCount"
} }
@Synchronized @Synchronized
@@ -173,7 +193,7 @@ object CacheBook {
model.book = book model.book = book
return model return model
} }
val model = CacheBookModel(bookSource, book) val model = CacheBookModel(bookSource, book, modelHost)
cacheBookMap[book.bookUrl] = model cacheBookMap[book.bookUrl] = model
updateSummary() updateSummary()
return model return model
@@ -211,56 +231,109 @@ object CacheBook {
fun start(context: Context, request: CacheDownloadRequest, isLocal: Boolean = false) { fun start(context: Context, request: CacheDownloadRequest, isLocal: Boolean = false) {
if (isLocal) return if (isLocal) return
when (val selection = request.selection) { if (!request.hasValidSelection()) return
is ChapterSelection.Range -> { isPaused = false
if (selection.end < selection.start) return
}
is ChapterSelection.Indices -> {
if (selection.values.isEmpty()) return
}
is ChapterSelection.Single -> Unit
}
val requestId = pendingRequestId.incrementAndGet()
pendingRequests[requestId] = request
context.startService<CacheBookService> { context.startService<CacheBookService> {
action = IntentAction.start action = IntentAction.start
putExtra("requestId", requestId) putRequestExtras(request)
putExtra("bookUrl", request.bookUrl)
putExtra("source", request.source.name)
when (val selection = request.selection) {
is ChapterSelection.Range -> {
putExtra("start", selection.start)
putExtra("end", selection.end)
}
is ChapterSelection.Indices -> Unit
is ChapterSelection.Single -> {
putExtra("start", selection.index)
putExtra("end", selection.index)
}
}
} }
} }
fun takePendingRequest(requestId: Long): CacheDownloadRequest? { fun start(context: Context, requests: List<CacheDownloadRequest>) {
return pendingRequests.remove(requestId) requests.asSequence()
.filter { it.hasValidSelection() }
.filter { request ->
appDb.bookDao.getBook(request.bookUrl)?.isLocal != true
}
.forEach { request ->
isPaused = false
context.startService<CacheBookService> {
action = IntentAction.start
putRequestExtras(request)
}
}
}
private fun android.content.Intent.putRequestExtras(request: CacheDownloadRequest) {
putExtra("bookUrl", request.bookUrl)
putExtra("source", request.source.name)
when (val selection = request.selection) {
is ChapterSelection.Range -> {
putExtra("start", selection.start)
putExtra("end", selection.end)
}
is ChapterSelection.Indices -> {
putExtra("indices", selection.values.toIntArray())
}
is ChapterSelection.Single -> {
putExtra("start", selection.index)
putExtra("end", selection.index)
}
}
} }
fun remove(context: Context, bookUrl: String) { fun remove(context: Context, bookUrl: String) {
if (!CacheBookService.isRun) {
removeBookFromService(bookUrl)
return
}
context.startService<CacheBookService> { context.startService<CacheBookService> {
action = IntentAction.remove action = IntentAction.remove
putExtra("bookUrl", bookUrl) putExtra("bookUrl", bookUrl)
} }
} }
fun removeBook(bookUrl: String): Boolean { suspend fun removeAwait(context: Context, bookUrl: String): Boolean {
if (!CacheBookService.isRun) {
return removeBookFromService(bookUrl)
}
val requestId = pendingRequestId.incrementAndGet()
val removeRequest = CompletableDeferred<Boolean>()
pendingRemoveRequests[requestId] = removeRequest
runCatching {
context.startService<CacheBookService> {
action = IntentAction.remove
putExtra("bookUrl", bookUrl)
putExtra("removeRequestId", requestId)
}
}.onFailure {
pendingRemoveRequests.remove(requestId)
removeRequest.completeExceptionally(it)
}
return try {
withTimeout(30_000L) {
removeRequest.await()
}
} catch (_: TimeoutCancellationException) {
pendingRemoveRequests.remove(requestId)
false
}
}
internal fun completePendingRemoveRequest(requestId: Long, removed: Boolean) {
pendingRemoveRequests.remove(requestId)?.complete(removed)
}
internal fun removeBookFromService(bookUrl: String): Boolean {
val model = cacheBookMap.remove(bookUrl) val model = cacheBookMap.remove(bookUrl)
model?.stop() model?.stop()
removePendingAdmission(bookUrl)
stateStore.removeBook(bookUrl) stateStore.removeBook(bookUrl)
updateSummary() updateSummary()
_queueChangedFlow.tryEmit(bookUrl) _queueChangedFlow.tryEmit(bookUrl)
return model != null return model != null
} }
internal fun removeModelFromService(bookUrl: String, model: CacheBookModel): Boolean {
val removed = cacheBookMap.remove(bookUrl, model)
if (!removed) return false
model.stop()
stateStore.removeBook(bookUrl)
updateSummary()
_queueChangedFlow.tryEmit(bookUrl)
return true
}
fun removeChapter(bookUrl: String, chapterIndex: Int): Boolean { fun removeChapter(bookUrl: String, chapterIndex: Int): Boolean {
return cacheBookMap[bookUrl]?.removeDownload(chapterIndex) == true return cacheBookMap[bookUrl]?.removeDownload(chapterIndex) == true
} }
@@ -273,13 +346,101 @@ object CacheBook {
} }
} }
fun close() { fun pause(context: Context) {
if (CacheBookService.isRun) {
context.startService<CacheBookService> {
action = IntentAction.pause
}
} else {
pauseAllFromService()
}
}
fun resume(context: Context): Boolean {
if (!hasQueuedDownloads) return false
isPaused = false
context.startService<CacheBookService> {
action = IntentAction.resume
}
return true
}
internal fun pauseAllFromService(): Boolean {
val hadTasks = hasQueuedDownloads
if (!hadTasks) return false
isPaused = true
cacheBookMap.forEach { (bookUrl, model) ->
model.pause()
_queueChangedFlow.tryEmit(bookUrl)
}
updateSummary()
return true
}
internal fun resumeFromService() {
isPaused = false
cacheBookMap.values.forEach { it.resume() }
updateSummary()
cacheBookMap.keys.forEach { _queueChangedFlow.tryEmit(it) }
}
fun pauseBook(context: Context, bookUrl: String): Boolean {
val paused = cacheBookMap[bookUrl]?.pause() == true
if (paused) {
updateSummary()
_queueChangedFlow.tryEmit(bookUrl)
}
return paused
}
fun resumeBook(context: Context, bookUrl: String): Boolean {
val resumed = cacheBookMap[bookUrl]?.resume() == true
if (resumed) {
isPaused = false
updateSummary()
_queueChangedFlow.tryEmit(bookUrl)
context.startService<CacheBookService> {
action = IntentAction.resume
}
}
return resumed
}
fun pauseChapter(bookUrl: String, chapterIndex: Int): Boolean {
val paused = cacheBookMap[bookUrl]?.pauseDownload(chapterIndex) == true
if (paused) {
updateSummary()
_queueChangedFlow.tryEmit(bookUrl)
}
return paused
}
fun resumeChapter(context: Context, bookUrl: String, chapterIndex: Int): Boolean {
val resumed = cacheBookMap[bookUrl]?.resumeDownload(chapterIndex) == true
if (resumed) {
isPaused = false
updateSummary()
_queueChangedFlow.tryEmit(bookUrl)
context.startService<CacheBookService> {
action = IntentAction.resume
}
}
return resumed
}
fun close(clearFailureState: Boolean = false) {
isPaused = false
cacheBookMap.forEach { (_, model) -> model.stop() } cacheBookMap.forEach { (_, model) -> model.stop() }
cacheBookMap.clear() cacheBookMap.clear()
successDownloadCount = 0 successDownloadCount.set(0)
errorRetryMap.clear() pendingRemoveRequests.values.forEach { it.complete(false) }
pendingRequests.clear() pendingRemoveRequests.clear()
stateStore.clear() clearPendingAdmissions()
if (clearFailureState) {
stateStore.clear()
} else {
stateStore.clearRuntimeState()
}
updateSummary() updateSummary()
} }
@@ -294,27 +455,119 @@ object CacheBook {
val totalCount: Int val totalCount: Int
get() { get() {
val stats = collectQueueStats() val stats = collectQueueStats()
return stats.waitingCount + stats.downloadingCount + successDownloadCount + stateStore.state.totalFailure return stats.waitingCount + stats.downloadingCount + successDownloadCount.get() + stateStore.state.totalFailure
} }
val completedCount: Int val completedCount: Int
get() = successDownloadCount + stateStore.state.totalFailure get() = successDownloadCount.get() + stateStore.state.totalFailure
val downloadSummary: String val downloadSummary: String
get() { get() {
val stats = collectQueueStats() val stats = collectQueueStats()
return "正在下载:${stats.downloadingCount} | 等待中:${stats.waitingCount} | 失败:${stateStore.state.totalFailure} | 成功:$successDownloadCount" return buildSummary(stats)
} }
val isRun: Boolean val isRun: Boolean
get() = lastQueueStats.waitingCount > 0 || lastQueueStats.downloadingCount > 0 get() = !isPaused && (
lastQueueStats.waitingCount > 0 ||
lastQueueStats.downloadingCount > 0 ||
cacheBookMap.values.any { it.hasQueuedDownloads() }
)
private fun onTaskQueuesChanged(bookUrl: String) { val hasQueuedDownloads: Boolean
get() = cacheBookMap.values.any { it.hasQueuedDownloads() } ||
_pendingAdmissionFlow.value.isNotEmpty()
val hasPausedDownloads: Boolean
get() = (isPaused && hasQueuedDownloads) || cacheBookMap.values.any { it.isPaused() }
val isGloballyPaused: Boolean
get() = isPaused && hasQueuedDownloads
private fun buildSummary(stats: QueueStats = collectQueueStats()): String {
val hasGlobalPause = isPaused && (stats.waitingCount > 0 || stats.downloadingCount > 0)
val downloadingCount = if (hasGlobalPause) 0 else stats.downloadingCount
val waitingCount = if (hasGlobalPause) 0 else stats.waitingCount
val modelPausedCount = cacheBookMap.values.sumOf {
it.pausedCount()
}
val pausedCount = maxOf(stateStore.state.totalPaused, modelPausedCount) + if (hasGlobalPause) {
stats.waitingCount + stats.downloadingCount
} else {
0
}
return "下载中:$downloadingCount | 等待:$waitingCount | 暂停:$pausedCount | 失败:${stateStore.state.totalFailure} | 已缓存:${successDownloadCount.get()}"
}
private fun CacheDownloadRequest.hasValidSelection(): Boolean {
return when (val selection = selection) {
is ChapterSelection.Range -> selection.end >= selection.start
is ChapterSelection.Indices -> selection.values.isNotEmpty()
is ChapterSelection.Single -> true
}
}
fun addPendingAdmissions(requests: Iterable<CacheDownloadRequest>) {
val counts = requests.groupingBy { it.bookUrl }
.fold(0) { count, request -> count + request.pendingChapterCount() }
.filterValues { it > 0 }
if (counts.isEmpty()) return
_pendingAdmissionFlow.update { pending ->
pending + counts.mapValues { (bookUrl, count) ->
pending[bookUrl].orZero() + count
}
}
updateSummary()
counts.keys.forEach { _queueChangedFlow.tryEmit(it) }
}
fun removePendingAdmission(request: CacheDownloadRequest) {
val chapterCount = request.pendingChapterCount()
if (chapterCount <= 0) return
_pendingAdmissionFlow.update { pending ->
val remaining = pending[request.bookUrl].orZero() - chapterCount
if (remaining > 0) {
pending + (request.bookUrl to remaining)
} else {
pending - request.bookUrl
}
}
updateSummary()
_queueChangedFlow.tryEmit(request.bookUrl)
}
fun removePendingAdmission(bookUrl: String) {
if (!_pendingAdmissionFlow.value.containsKey(bookUrl)) return
_pendingAdmissionFlow.update { it - bookUrl }
updateSummary()
_queueChangedFlow.tryEmit(bookUrl)
}
private fun clearPendingAdmissions() {
val bookUrls = _pendingAdmissionFlow.value.keys
if (bookUrls.isEmpty()) return
_pendingAdmissionFlow.value = emptyMap()
updateSummary()
bookUrls.forEach { _queueChangedFlow.tryEmit(it) }
}
private fun CacheDownloadRequest.pendingChapterCount(): Int {
return when (val selection = selection) {
is ChapterSelection.Range -> selection.end - selection.start + 1
is ChapterSelection.Indices -> selection.values.size
is ChapterSelection.Single -> 1
}
}
private fun Int?.orZero(): Int = this ?: 0
private fun notifyTaskQueuesChanged(bookUrl: String) {
cacheBookMap[bookUrl]?.let { model -> cacheBookMap[bookUrl]?.let { model ->
stateStore.updateBookQueue( stateStore.updateBookQueue(
bookUrl = bookUrl, bookUrl = bookUrl,
waitingCount = model.queueCounts().first, waitingCount = model.queueCounts().first,
runningIndices = model.downloadingIndices(), runningIndices = model.downloadingIndices(),
pausedIndices = model.pausedIndices(),
) )
_downloadingIndicesFlow.tryEmit(bookUrl to model.downloadingIndices()) _downloadingIndicesFlow.tryEmit(bookUrl to model.downloadingIndices())
_downloadErrorFlow.tryEmit(bookUrl to errorIndices(bookUrl)) _downloadErrorFlow.tryEmit(bookUrl to errorIndices(bookUrl))
@@ -323,7 +576,7 @@ object CacheBook {
_queueChangedFlow.tryEmit(bookUrl) _queueChangedFlow.tryEmit(bookUrl)
} }
private fun onTaskRemoved(bookUrl: String, clearState: Boolean = false) { private fun notifyTaskRemoved(bookUrl: String, clearState: Boolean = false) {
cacheBookMap.remove(bookUrl) cacheBookMap.remove(bookUrl)
if (clearState) { if (clearState) {
stateStore.removeBook(bookUrl) stateStore.removeBook(bookUrl)
@@ -332,368 +585,29 @@ object CacheBook {
_queueChangedFlow.tryEmit(bookUrl) _queueChangedFlow.tryEmit(bookUrl)
} }
class CacheBookModel( private class ModelHostImpl : CacheBookModel.Host {
@Volatile var bookSource: BookSource, override val stateStore: CacheDownloadStateStore
@Volatile var book: Book get() = CacheBook.stateStore
) { override val cacheBookMap: ConcurrentHashMap<String, CacheBookModel>
get() = CacheBook.cacheBookMap
private val queue = CacheDownloadQueue() override fun incrementSuccessCount(): Int = CacheBook.successDownloadCount.incrementAndGet()
private val onDownloadSet = linkedSetOf<Int>() override fun onTaskQueuesChanged(bookUrl: String) {
private val pausedDownloadSet = hashSetOf<Int>() CacheBook.notifyTaskQueuesChanged(bookUrl)
private val chapterTasks = hashMapOf<Int, Coroutine<*>>()
private val tasks = CompositeCoroutine()
private val repository = CacheDownloadRepository()
private var isStopped = false
private var waitingRetry = false
private var isLoading = false
private fun notifyDownloadSetChanged() {
_downloadingIndicesFlow.tryEmit(book.bookUrl to onDownloadSet.toSet())
if (cacheBookMap[book.bookUrl] === this) {
stateStore.updateBookQueue(
bookUrl = book.bookUrl,
waitingCount = queue.waitingCount(),
runningIndices = onDownloadSet.toSet(),
)
}
} }
override fun onTaskRemoved(bookUrl: String, clearState: Boolean) {
private fun notifyErrorChanged() { CacheBook.notifyTaskRemoved(bookUrl, clearState)
val errors = errorIndices(book.bookUrl)
_downloadErrorFlow.tryEmit(book.bookUrl to errors)
} }
override fun emitDownloadingIndices(bookUrl: String, indices: Set<Int>) {
@Synchronized CacheBook._downloadingIndicesFlow.tryEmit(bookUrl to indices)
fun queueCounts(): Pair<Int, Int> = queue.waitingCount() to onDownloadSet.size
@Synchronized
fun isWaiting(index: Int): Boolean = queue.isWaiting(index)
@Synchronized
fun isDownloading(index: Int): Boolean = onDownloadSet.contains(index)
@Synchronized
fun downloadingIndices(): Set<Int> = onDownloadSet.toSet()
@Synchronized
fun isRun(): Boolean {
return queue.waitingCount() > 0 || onDownloadSet.isNotEmpty() || isLoading
} }
override fun emitDownloadError(bookUrl: String, indices: Set<Int>) {
@Synchronized CacheBook._downloadErrorFlow.tryEmit(bookUrl to indices)
fun isStop(): Boolean {
return isStopped || (!isRun() && !waitingRetry)
} }
override fun emitChapterCached(chapter: BookChapter) {
@Synchronized CacheBook._cacheSuccessFlow.tryEmit(chapter)
fun isLoading(): Boolean = isLoading
@Synchronized
fun setLoading() {
isLoading = true
CacheBook.onTaskQueuesChanged(book.bookUrl)
}
@Synchronized
fun stop() {
queue.clear()
pausedDownloadSet.clear()
chapterTasks.clear()
tasks.clear()
isStopped = true
isLoading = false
onDownloadSet.clear()
notifyDownloadSetChanged()
CacheBook.onTaskQueuesChanged(book.bookUrl)
}
@Synchronized
fun addDownload(start: Int, end: Int) {
addRequest(
CacheDownloadRequest(
book.bookUrl,
ChapterSelection.Range(start, end),
CacheDownloadSource.ReadPreload,
)
)
}
@Synchronized
fun addDownloads(indices: Iterable<Int>) {
val values = indices.toSet()
if (values.isEmpty()) return
addRequest(
CacheDownloadRequest(
book.bookUrl,
ChapterSelection.Indices(values),
CacheDownloadSource.Manual,
)
)
}
@Synchronized
fun addRequest(request: CacheDownloadRequest) {
isStopped = false
when (val selection = request.selection) {
is ChapterSelection.Range -> {
pausedDownloadSet.removeAll { it in selection.start..selection.end }
}
is ChapterSelection.Indices -> selection.values.forEach { pausedDownloadSet.remove(it) }
is ChapterSelection.Single -> pausedDownloadSet.remove(selection.index)
}
queue.enqueue(request)
cacheBookMap[book.bookUrl] = this
isLoading = false
notifyDownloadSetChanged()
CacheBook.onTaskQueuesChanged(book.bookUrl)
}
fun addDownload(index: Int) {
addDownload(index, index)
}
@Synchronized
private fun onSuccess(chapter: BookChapter) {
onDownloadSet.remove(chapter.index)
chapterTasks.remove(chapter.index)
val chapterKey = ChapterKey(book.bookUrl, chapter.index)
successDownloadCount++
errorRetryMap.remove(chapterKey)
stateStore.markSuccess(book.bookUrl, chapter.index)
notifyDownloadSetChanged()
notifyErrorChanged()
_cacheSuccessFlow.tryEmit(chapter)
}
@Synchronized
private fun onPreError(chapter: BookChapter, error: Throwable) {
waitingRetry = true
if (error !is ConcurrentException) {
errorRetryMap.merge(ChapterKey(book.bookUrl, chapter.index), 1) { old, inc -> old + inc }
stateStore.markFailed(book.bookUrl, chapter.index)
}
onDownloadSet.remove(chapter.index)
chapterTasks.remove(chapter.index)
}
@Synchronized
private fun onPostError(chapter: BookChapter, error: Throwable) {
val retryCount = errorRetryMap[ChapterKey(book.bookUrl, chapter.index)] ?: 0
if (retryCount < 3 && !isStopped) {
queue.enqueue(ChapterSelection.Single(chapter.index))
} else {
AppLog.put("下载${book.name}-${chapter.title}失败\n${error.localizedMessage}", error)
}
waitingRetry = false
}
@Synchronized
private fun onError(chapter: BookChapter, error: Throwable) {
onPreError(chapter, error)
onPostError(chapter, error)
notifyDownloadSetChanged()
notifyErrorChanged()
}
@Synchronized
private fun onCancel(index: Int) {
onDownloadSet.remove(index)
chapterTasks.remove(index)
if (!isStopped && !pausedDownloadSet.remove(index)) {
queue.enqueue(ChapterSelection.Single(index))
}
notifyDownloadSetChanged()
}
@Synchronized
private fun onFinally() {
val bookUrl = book.bookUrl
if (queue.waitingCount() == 0 && onDownloadSet.isEmpty()) {
CacheBook.onTaskRemoved(bookUrl)
} else {
CacheBook.onTaskQueuesChanged(bookUrl)
}
notifyDownloadSetChanged()
}
@Synchronized
fun removeDownload(index: Int): Boolean {
val removedWaiting = queue.removeChapter(index)
val task = chapterTasks.remove(index)
val removedRunning = onDownloadSet.contains(index) || task != null
if (removedRunning) {
pausedDownloadSet.add(index)
task?.let {
tasks.delete(it)
it.cancel()
}
}
if (!removedWaiting && !removedRunning) return false
notifyDownloadSetChanged()
if (queue.waitingCount() == 0 && onDownloadSet.isEmpty()) {
CacheBook.onTaskRemoved(book.bookUrl, clearState = true)
} else {
CacheBook.onTaskQueuesChanged(book.bookUrl)
}
return true
}
/**
* 从待下载列表内取第一条下载
*/
@Synchronized
fun download(scope: CoroutineScope, context: CoroutineContext) {
val candidate = queue.next(book.bookUrl, onDownloadSet)
if (candidate == null) {
if (!isLoading && onDownloadSet.isEmpty()) {
CacheBook.onTaskRemoved(book.bookUrl)
}
return
}
val chapterIndex = candidate.chapterIndex
if (onDownloadSet.contains(chapterIndex)) {
return
}
val chapter = repository.getChapter(book.bookUrl, chapterIndex) ?: run {
return
}
if (chapter.isVolume) {
_cacheSuccessFlow.tryEmit(chapter)
return
}
if (repository.hasImageContent(book, chapter)) {
return
}
onDownloadSet.add(chapterIndex)
notifyDownloadSetChanged()
if (repository.hasContent(book, chapter)) {
val task = repository.saveCachedImagesTask(
scope = scope,
context = context,
bookSource = bookSource,
book = book,
chapter = chapter,
).onSuccess {
onSuccess(chapter)
}.onError {
onPreError(chapter, it)
delay(1000)
onPostError(chapter, it)
}.onCancel {
onCancel(chapterIndex)
}.onFinally {
chapterTasks.remove(chapterIndex)?.let { tasks.delete(it) }
onFinally()
}
chapterTasks[chapterIndex] = task
tasks.add(task)
return
}
val task = repository.cacheContentTask(
scope = scope,
bookSource = bookSource,
book = book,
chapter = chapter,
context = context,
start = CoroutineStart.LAZY,
executeContext = context,
).onSuccess {
onSuccess(chapter)
}.onError {
onPreError(chapter, it)
delay(1000)
onPostError(chapter, it)
}.onCancel {
onCancel(chapterIndex)
}.onFinally {
chapterTasks.remove(chapterIndex)?.let { tasks.delete(it) }
onFinally()
}
chapterTasks[chapterIndex] = task
tasks.add(task)
task.start()
}
suspend fun downloadAwait(chapter: BookChapter): String {
synchronized(this) {
onDownloadSet.add(chapter.index)
queue.removeChapter(chapter.index)
notifyDownloadSetChanged()
}
try {
val content = repository.downloadContentAwait(bookSource, book, chapter)
onSuccess(chapter)
ReadBook.downloadedChapters.add(chapter.index)
ReadBook.downloadFailChapters.remove(chapter.index)
return content
} catch (e: Exception) {
if (e is CancellationException) onCancel(chapter.index)
onError(chapter, e)
ReadBook.downloadFailChapters[chapter.index] =
(ReadBook.downloadFailChapters[chapter.index] ?: 0) + 1
return "获取正文失败\n${e.localizedMessage}"
} finally {
CacheBook.onTaskQueuesChanged(book.bookUrl)
}
}
@Synchronized
fun download(
scope: CoroutineScope,
chapter: BookChapter,
semaphore: Semaphore?,
resetPageOffset: Boolean = false
) {
if (onDownloadSet.contains(chapter.index)) return
onDownloadSet.add(chapter.index)
queue.removeChapter(chapter.index)
notifyDownloadSetChanged()
repository.downloadContentTask(
scope = scope,
bookSource = bookSource,
book = book,
chapter = chapter,
start = CoroutineStart.LAZY,
context = IO,
executeContext = IO,
semaphore = semaphore
).onSuccess { content ->
onSuccess(chapter)
ReadBook.downloadedChapters.add(chapter.index)
ReadBook.downloadFailChapters.remove(chapter.index)
downloadFinish(chapter, content, resetPageOffset)
}.onError {
onError(chapter, it)
ReadBook.downloadFailChapters[chapter.index] =
(ReadBook.downloadFailChapters[chapter.index] ?: 0) + 1
downloadFinish(chapter, "获取正文失败\n${it.localizedMessage}", resetPageOffset)
}.onCancel {
onCancel(chapter.index)
downloadFinish(chapter, "download canceled", resetPageOffset, true)
}.onFinally {
CacheBook.onTaskQueuesChanged(book.bookUrl)
}.start()
}
private fun downloadFinish(
chapter: BookChapter,
content: String,
resetPageOffset: Boolean = false,
canceled: Boolean = false
) {
ReadingCacheEvents.emit(
ReadingCacheEvent.ContentReady(
book = book,
chapter = chapter,
content = content,
resetPageOffset = resetPageOffset,
canceled = canceled,
)
)
} }
override fun errorIndices(bookUrl: String): Set<Int> =
CacheBook.errorIndices(bookUrl)
} }
} }
@@ -0,0 +1,592 @@
package io.legado.app.model
import io.legado.app.constant.AppLog
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookSource
import io.legado.app.exception.ConcurrentException
import io.legado.app.help.coroutine.CompositeCoroutine
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.model.cache.CacheDownloadCandidate
import io.legado.app.model.cache.CacheDownloadQueue
import io.legado.app.model.cache.CacheDownloadRepository
import io.legado.app.model.cache.CacheDownloadRequest
import io.legado.app.model.cache.CacheDownloadSource
import io.legado.app.model.cache.CacheDownloadStateStore
import io.legado.app.model.cache.ChapterSelection
import io.legado.app.model.cache.ReadingCacheEvent
import io.legado.app.model.cache.ReadingCacheEvents
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Semaphore
import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.CoroutineContext
class CacheBookModel(
@Volatile var bookSource: BookSource,
@Volatile var book: Book,
private val host: Host,
) {
interface Host {
val stateStore: CacheDownloadStateStore
val cacheBookMap: ConcurrentHashMap<String, CacheBookModel>
fun incrementSuccessCount(): Int
fun onTaskQueuesChanged(bookUrl: String)
fun onTaskRemoved(bookUrl: String, clearState: Boolean = false)
fun emitDownloadingIndices(bookUrl: String, indices: Set<Int>)
fun emitDownloadError(bookUrl: String, indices: Set<Int>)
fun emitChapterCached(chapter: BookChapter)
fun errorIndices(bookUrl: String): Set<Int>
}
data class Diagnostics(
val waitingChapterCount: Int,
val runningChapterCount: Int,
val trackedChapterTaskCount: Int,
val isLoading: Boolean,
val waitingRetry: Boolean,
)
private val queue = CacheDownloadQueue()
private val onDownloadSet = linkedSetOf<Int>()
private val canceledDownloadSet = hashSetOf<Int>()
private val pausedChapterSet = linkedSetOf<Int>()
private val chapterTasks = hashMapOf<Int, Coroutine<*>>()
private val tasks = CompositeCoroutine()
private val repository = CacheDownloadRepository()
private val retryCountMap = hashMapOf<Int, Int>()
private var isStopped = false
private var waitingRetry = false
private var isLoading = false
private var isPaused = false
private fun notifyDownloadSetChanged() {
host.emitDownloadingIndices(book.bookUrl, onDownloadSet.toSet())
if (host.cacheBookMap[book.bookUrl] === this) {
host.stateStore.updateBookQueue(
bookUrl = book.bookUrl,
waitingCount = queue.waitingCount(),
runningIndices = onDownloadSet.toSet(),
pausedIndices = pausedChapterSet.toSet(),
)
}
}
private fun notifyErrorChanged() {
val errors = host.errorIndices(book.bookUrl)
host.emitDownloadError(book.bookUrl, errors)
}
@Synchronized
fun queueCounts(): Pair<Int, Int> = queue.waitingCount() to onDownloadSet.size
@Synchronized
fun isWaiting(index: Int): Boolean = queue.isWaiting(index)
@Synchronized
fun isDownloading(index: Int): Boolean = onDownloadSet.contains(index)
@Synchronized
fun isPaused(): Boolean = isPaused
@Synchronized
fun isPaused(index: Int): Boolean {
return pausedChapterSet.contains(index) ||
(isPaused && (queue.isWaiting(index) || onDownloadSet.contains(index)))
}
@Synchronized
fun downloadingIndices(): Set<Int> = onDownloadSet.toSet()
@Synchronized
fun pausedIndices(): Set<Int> = pausedChapterSet.toSet()
@Synchronized
fun pausedCount(): Int {
return pausedChapterSet.size + if (isPaused) {
queue.waitingCount() + onDownloadSet.size
} else {
0
}
}
@Synchronized
fun isRun(): Boolean {
return queue.waitingCount() > 0 || onDownloadSet.isNotEmpty() || isLoading || chapterTasks.isNotEmpty()
}
@Synchronized
fun isStop(): Boolean {
return isStopped || (!isRun() && !waitingRetry)
}
@Synchronized
fun isLoading(): Boolean = isLoading
@Synchronized
fun hasQueuedDownloads(): Boolean {
return queue.waitingCount() > 0 ||
onDownloadSet.isNotEmpty() ||
pausedChapterSet.isNotEmpty() ||
isLoading ||
chapterTasks.isNotEmpty()
}
@Synchronized
fun hasRunnableDownloads(): Boolean {
return !isPaused && (
queue.waitingCount() > 0 ||
onDownloadSet.isNotEmpty() ||
isLoading ||
chapterTasks.isNotEmpty()
)
}
@Synchronized
fun diagnostics(): Diagnostics {
return Diagnostics(
waitingChapterCount = queue.waitingCount(),
runningChapterCount = onDownloadSet.size,
trackedChapterTaskCount = chapterTasks.size,
isLoading = isLoading,
waitingRetry = waitingRetry,
)
}
@Synchronized
fun setLoading() {
isLoading = true
host.onTaskQueuesChanged(book.bookUrl)
}
@Synchronized
fun pause(): Boolean {
if (!hasQueuedDownloads()) return false
isPaused = true
isLoading = false
waitingRetry = false
chapterTasks.values.toList().forEach { task ->
tasks.delete(task)
task.cancel()
}
notifyDownloadSetChanged()
host.onTaskQueuesChanged(book.bookUrl)
return true
}
@Synchronized
fun resume(): Boolean {
if (!isPaused && pausedChapterSet.isEmpty()) return false
isPaused = false
if (pausedChapterSet.isNotEmpty()) {
queue.enqueue(ChapterSelection.Indices(pausedChapterSet.toSet()))
pausedChapterSet.clear()
}
notifyDownloadSetChanged()
host.onTaskQueuesChanged(book.bookUrl)
return true
}
@Synchronized
fun stop() {
queue.clear()
canceledDownloadSet.clear()
pausedChapterSet.clear()
chapterTasks.clear()
tasks.clear()
retryCountMap.clear()
isStopped = true
isPaused = false
isLoading = false
onDownloadSet.clear()
notifyDownloadSetChanged()
host.onTaskQueuesChanged(book.bookUrl)
}
@Synchronized
fun addDownload(start: Int, end: Int) {
addRequest(
CacheDownloadRequest(
book.bookUrl,
ChapterSelection.Range(start, end),
CacheDownloadSource.ReadPreload,
)
)
}
@Synchronized
fun addDownloads(indices: Iterable<Int>) {
val values = indices.toSet()
if (values.isEmpty()) return
addRequest(
CacheDownloadRequest(
book.bookUrl,
ChapterSelection.Indices(values),
CacheDownloadSource.Manual,
)
)
}
@Synchronized
fun addRequest(request: CacheDownloadRequest) {
isStopped = false
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)
}
}
queue.enqueue(request)
host.cacheBookMap[book.bookUrl] = this
isLoading = false
notifyDownloadSetChanged()
host.onTaskQueuesChanged(book.bookUrl)
}
fun addDownload(index: Int) {
addDownload(index, index)
}
@Synchronized
private fun onSuccess(chapter: BookChapter) {
onDownloadSet.remove(chapter.index)
chapterTasks.remove(chapter.index)
host.incrementSuccessCount()
retryCountMap.remove(chapter.index)
host.stateStore.markSuccess(book.bookUrl, chapter.index)
notifyDownloadSetChanged()
notifyErrorChanged()
host.emitChapterCached(chapter)
}
@Synchronized
private fun onPreError(chapter: BookChapter, error: Throwable) {
waitingRetry = true
if (error !is ConcurrentException) {
retryCountMap[chapter.index] = (retryCountMap[chapter.index] ?: 0) + 1
host.stateStore.markFailed(book.bookUrl, chapter.index)
}
onDownloadSet.remove(chapter.index)
chapterTasks.remove(chapter.index)
}
@Synchronized
private fun onPostError(chapter: BookChapter, error: Throwable) {
val retryCount = retryCountMap[chapter.index] ?: 0
if (retryCount < 3 && !isStopped) {
queue.enqueue(ChapterSelection.Single(chapter.index))
} else {
AppLog.put("下载${book.name}-${chapter.title}失败\n${error.localizedMessage}", error)
}
waitingRetry = false
}
@Synchronized
private fun onError(chapter: BookChapter, error: Throwable) {
onPreError(chapter, error)
onPostError(chapter, error)
notifyDownloadSetChanged()
notifyErrorChanged()
}
@Synchronized
private fun onCancel(index: Int, requeue: Boolean = true) {
onDownloadSet.remove(index)
chapterTasks.remove(index)
val wasCanceled = canceledDownloadSet.remove(index)
if (requeue && !isStopped && !wasCanceled) {
queue.enqueue(ChapterSelection.Single(index))
}
notifyDownloadSetChanged()
}
@Synchronized
private fun onFinally() {
val bookUrl = book.bookUrl
if (queue.waitingCount() == 0 && onDownloadSet.isEmpty() && pausedChapterSet.isEmpty()) {
host.onTaskRemoved(bookUrl)
} else {
host.onTaskQueuesChanged(bookUrl)
}
notifyDownloadSetChanged()
}
@Synchronized
fun removeDownload(index: Int): Boolean {
val removedWaiting = queue.removeChapter(index)
val removedPaused = pausedChapterSet.remove(index)
val task = chapterTasks.remove(index)
val removedRunning = onDownloadSet.contains(index) || task != null
if (removedRunning) {
canceledDownloadSet.add(index)
onDownloadSet.remove(index)
task?.let {
tasks.delete(it)
it.cancel()
}
}
if (!removedWaiting && !removedRunning && !removedPaused) return false
notifyDownloadSetChanged()
if (queue.waitingCount() == 0 && onDownloadSet.isEmpty() && pausedChapterSet.isEmpty()) {
host.onTaskRemoved(book.bookUrl, clearState = true)
} else {
host.onTaskQueuesChanged(book.bookUrl)
}
return true
}
@Synchronized
fun pauseDownload(index: Int): Boolean {
val removedWaiting = queue.removeChapter(index)
val task = chapterTasks.remove(index)
val removedRunning = onDownloadSet.contains(index) || task != null
if (!removedWaiting && !removedRunning) return false
pausedChapterSet.add(index)
if (removedRunning) {
canceledDownloadSet.add(index)
onDownloadSet.remove(index)
task?.let {
tasks.delete(it)
it.cancel()
}
}
notifyDownloadSetChanged()
host.onTaskQueuesChanged(book.bookUrl)
return true
}
@Synchronized
fun resumeDownload(index: Int): Boolean {
if (!pausedChapterSet.remove(index)) return false
isPaused = false
queue.enqueue(ChapterSelection.Single(index))
notifyDownloadSetChanged()
host.onTaskQueuesChanged(book.bookUrl)
return true
}
/**
* 从待下载列表内取第一条下载
*/
fun download(scope: CoroutineScope, context: CoroutineContext) {
val candidate = nextDownloadCandidate() ?: return
val chapterIndex = candidate.chapterIndex
val chapter = repository.getChapter(book.bookUrl, chapterIndex) ?: run {
onSkipped(chapterIndex)
return
}
if (chapter.isVolume) {
host.emitChapterCached(chapter)
onSkipped(chapterIndex)
return
}
if (repository.hasImageContent(book, chapter)) {
onSkipped(chapterIndex)
return
}
if (repository.hasContent(book, chapter)) {
val task = repository.saveCachedImagesTask(
scope = scope,
context = context,
bookSource = bookSource,
book = book,
chapter = chapter,
start = CoroutineStart.LAZY,
)
if (!attachTaskIfActive(task, chapter, chapterIndex)) {
task.cancel()
return
}
task.start()
return
}
val task = repository.cacheContentTask(
scope = scope,
bookSource = bookSource,
book = book,
chapter = chapter,
context = context,
start = CoroutineStart.LAZY,
executeContext = context,
)
if (!attachTaskIfActive(task, chapter, chapterIndex)) {
task.cancel()
return
}
task.start()
}
@Synchronized
private fun nextDownloadCandidate(): CacheDownloadCandidate? {
if (isPaused) return null
val candidate = queue.next(book.bookUrl, onDownloadSet)
if (candidate == null) {
notifyDownloadSetChanged()
if (!isLoading && onDownloadSet.isEmpty() && pausedChapterSet.isEmpty()) {
host.onTaskRemoved(book.bookUrl)
} else {
host.onTaskQueuesChanged(book.bookUrl)
}
return null
}
val chapterIndex = candidate.chapterIndex
if (onDownloadSet.contains(chapterIndex)) {
return null
}
onDownloadSet.add(chapterIndex)
notifyDownloadSetChanged()
return candidate
}
@Synchronized
private fun onSkipped(index: Int) {
onDownloadSet.remove(index)
chapterTasks.remove(index)
notifyDownloadSetChanged()
if (queue.waitingCount() == 0 && onDownloadSet.isEmpty() && pausedChapterSet.isEmpty() && !isLoading) {
host.onTaskRemoved(book.bookUrl)
} else {
host.onTaskQueuesChanged(book.bookUrl)
}
}
@Synchronized
private fun <T> attachTaskIfActive(
task: Coroutine<T>,
chapter: BookChapter,
chapterIndex: Int,
): Boolean {
if (isStopped || isPaused || !onDownloadSet.contains(chapterIndex)) {
if (!isStopped && isPaused && onDownloadSet.remove(chapterIndex)) {
queue.enqueue(ChapterSelection.Single(chapterIndex))
notifyDownloadSetChanged()
host.onTaskQueuesChanged(book.bookUrl)
}
return false
}
attachCallbacks(task, chapter, chapterIndex)
chapterTasks[chapterIndex] = task
tasks.add(task)
return true
}
private fun <T> attachCallbacks(
task: Coroutine<T>,
chapter: BookChapter,
chapterIndex: Int,
) {
task.onSuccess {
onSuccess(chapter)
}.onError {
onPreError(chapter, it)
delay(1000)
onPostError(chapter, it)
}.onCancel {
onCancel(chapterIndex)
}.onFinally {
chapterTasks.remove(chapterIndex)?.let { tasks.delete(it) }
onFinally()
}
}
suspend fun downloadAwait(chapter: BookChapter): String {
synchronized(this) {
onDownloadSet.add(chapter.index)
queue.removeChapter(chapter.index)
notifyDownloadSetChanged()
}
try {
val content = repository.downloadContentAwait(bookSource, book, chapter)
onSuccess(chapter)
ReadBook.downloadedChapters.add(chapter.index)
ReadBook.downloadFailChapters.remove(chapter.index)
return content
} catch (e: Exception) {
if (e is CancellationException) {
onCancel(chapter.index, requeue = false)
return "download canceled"
}
onError(chapter, e)
ReadBook.downloadFailChapters[chapter.index] =
(ReadBook.downloadFailChapters[chapter.index] ?: 0) + 1
return "获取正文失败\n${e.localizedMessage}"
} finally {
host.onTaskQueuesChanged(book.bookUrl)
}
}
fun download(
scope: CoroutineScope,
chapter: BookChapter,
semaphore: Semaphore?,
resetPageOffset: Boolean = false
) {
if (!markChapterDownloadStarted(chapter.index)) return
repository.downloadContentTask(
scope = scope,
bookSource = bookSource,
book = book,
chapter = chapter,
start = CoroutineStart.LAZY,
context = IO,
executeContext = IO,
semaphore = semaphore
).onSuccess { content ->
onSuccess(chapter)
ReadBook.downloadedChapters.add(chapter.index)
ReadBook.downloadFailChapters.remove(chapter.index)
downloadFinish(chapter, content, resetPageOffset)
}.onError {
onError(chapter, it)
ReadBook.downloadFailChapters[chapter.index] =
(ReadBook.downloadFailChapters[chapter.index] ?: 0) + 1
downloadFinish(chapter, "获取正文失败\n${it.localizedMessage}", resetPageOffset)
}.onCancel {
onCancel(chapter.index, requeue = false)
downloadFinish(chapter, "download canceled", resetPageOffset, true)
}.onFinally {
host.onTaskQueuesChanged(book.bookUrl)
}.start()
}
@Synchronized
private fun markChapterDownloadStarted(index: Int): Boolean {
if (onDownloadSet.contains(index)) return false
onDownloadSet.add(index)
queue.removeChapter(index)
notifyDownloadSetChanged()
return true
}
private fun downloadFinish(
chapter: BookChapter,
content: String,
resetPageOffset: Boolean = false,
canceled: Boolean = false
) {
ReadingCacheEvents.emit(
ReadingCacheEvent.ContentReady(
book = book,
chapter = chapter,
content = content,
resetPageOffset = resetPageOffset,
canceled = canceled,
)
)
}
}
@@ -0,0 +1,51 @@
package io.legado.app.model.cache
class CacheDownloadAdmissionQueue(
private val maxActiveBooks: Int
) {
private val requests = ArrayDeque<CacheDownloadRequest>()
val size: Int
get() = requests.size
fun isEmpty(): Boolean = requests.isEmpty()
fun shouldQueue(request: CacheDownloadRequest, activeBookUrls: Set<String>): Boolean {
return request.bookUrl !in activeBookUrls && activeBookUrls.size >= maxActiveBooks
}
fun add(request: CacheDownloadRequest) {
requests.addLast(request)
}
fun pollReady(activeBookUrls: Set<String>): CacheDownloadRequest? {
if (requests.isEmpty()) return null
val activeBookRequestIndex = requests.indexOfFirst {
it.bookUrl in activeBookUrls
}
if (activeBookRequestIndex >= 0) {
return requests.removeAt(activeBookRequestIndex)
}
if (activeBookUrls.size >= maxActiveBooks) return null
return requests.removeFirst()
}
fun removeBook(bookUrl: String): Boolean {
var removed = false
val iterator = requests.iterator()
while (iterator.hasNext()) {
if (iterator.next().bookUrl == bookUrl) {
iterator.remove()
removed = true
}
}
return removed
}
fun clear() {
requests.clear()
}
}
@@ -31,6 +31,7 @@ data class CacheDownloadState(
val isRunning: Boolean = false, val isRunning: Boolean = false,
val totalWaiting: Int = 0, val totalWaiting: Int = 0,
val totalRunning: Int = 0, val totalRunning: Int = 0,
val totalPaused: Int = 0,
val totalSuccess: Int = 0, val totalSuccess: Int = 0,
val totalFailure: Int = 0, val totalFailure: Int = 0,
val books: Map<String, CacheBookDownloadState> = emptyMap(), val books: Map<String, CacheBookDownloadState> = emptyMap(),
@@ -40,6 +41,7 @@ data class CacheBookDownloadState(
val bookUrl: String, val bookUrl: String,
val waitingCount: Int = 0, val waitingCount: Int = 0,
val runningIndices: Set<Int> = emptySet(), val runningIndices: Set<Int> = emptySet(),
val pausedIndices: Set<Int> = emptySet(),
val failedIndices: Set<Int> = emptySet(), val failedIndices: Set<Int> = emptySet(),
val successCount: Int = 0, val successCount: Int = 0,
val failureMessage: String? = null, val failureMessage: String? = null,
@@ -32,8 +32,9 @@ class CacheDownloadRepository {
bookSource: BookSource, bookSource: BookSource,
book: Book, book: Book,
chapter: BookChapter, chapter: BookChapter,
start: CoroutineStart = CoroutineStart.LAZY,
): Coroutine<Unit> { ): Coroutine<Unit> {
return Coroutine.async(scope, context, executeContext = context) { return Coroutine.async(scope, context, start = start, executeContext = context) {
BookHelp.getContent(book, chapter)?.let { BookHelp.getContent(book, chapter)?.let {
BookHelp.saveImages(bookSource, book, chapter, it, 1) BookHelp.saveImages(bookSource, book, chapter, it, 1)
} }
@@ -16,12 +16,14 @@ class CacheDownloadStateStore {
bookUrl: String, bookUrl: String,
waitingCount: Int, waitingCount: Int,
runningIndices: Set<Int>, runningIndices: Set<Int>,
pausedIndices: Set<Int> = emptySet(),
) { ) {
updateBook(bookUrl) { current -> updateBook(bookUrl) { current ->
current.copy( current.copy(
waitingCount = waitingCount, waitingCount = waitingCount,
runningIndices = runningIndices, runningIndices = runningIndices,
failureMessage = if (waitingCount > 0 || runningIndices.isNotEmpty()) { pausedIndices = pausedIndices,
failureMessage = if (waitingCount > 0 || runningIndices.isNotEmpty() || pausedIndices.isNotEmpty()) {
null null
} else { } else {
current.failureMessage current.failureMessage
@@ -34,6 +36,7 @@ class CacheDownloadStateStore {
updateBook(bookUrl) { current -> updateBook(bookUrl) { current ->
current.copy( current.copy(
runningIndices = current.runningIndices - chapterIndex, runningIndices = current.runningIndices - chapterIndex,
pausedIndices = current.pausedIndices - chapterIndex,
failedIndices = current.failedIndices - chapterIndex, failedIndices = current.failedIndices - chapterIndex,
successCount = current.successCount + 1, successCount = current.successCount + 1,
failureMessage = null, failureMessage = null,
@@ -45,6 +48,7 @@ class CacheDownloadStateStore {
updateBook(bookUrl) { current -> updateBook(bookUrl) { current ->
current.copy( current.copy(
runningIndices = current.runningIndices - chapterIndex, runningIndices = current.runningIndices - chapterIndex,
pausedIndices = current.pausedIndices - chapterIndex,
failedIndices = current.failedIndices + chapterIndex, failedIndices = current.failedIndices + chapterIndex,
) )
} }
@@ -55,6 +59,8 @@ class CacheDownloadStateStore {
current.copy( current.copy(
waitingCount = 0, waitingCount = 0,
runningIndices = emptySet(), runningIndices = emptySet(),
pausedIndices = emptySet(),
failedIndices = emptySet(),
failureMessage = message, failureMessage = message,
) )
} }
@@ -76,6 +82,25 @@ class CacheDownloadStateStore {
_stateFlow.value = CacheDownloadState() _stateFlow.value = CacheDownloadState()
} }
fun clearRuntimeState() {
_stateFlow.update { state ->
val failureBooks = state.books
.mapValues { (_, bookState) ->
bookState.copy(
waitingCount = 0,
runningIndices = emptySet(),
successCount = 0,
)
}
.filterValues { bookState ->
bookState.pausedIndices.isNotEmpty() ||
bookState.failedIndices.isNotEmpty() ||
bookState.failureMessage != null
}
state.copy(books = failureBooks).recalculate()
}
}
fun bookState(bookUrl: String): CacheBookDownloadState? { fun bookState(bookUrl: String): CacheBookDownloadState? {
return state.books[bookUrl] return state.books[bookUrl]
} }
@@ -93,13 +118,15 @@ class CacheDownloadStateStore {
private fun CacheDownloadState.recalculate(): CacheDownloadState { private fun CacheDownloadState.recalculate(): CacheDownloadState {
val totalWaiting = books.values.sumOf { it.waitingCount } val totalWaiting = books.values.sumOf { it.waitingCount }
val totalRunning = books.values.sumOf { it.runningIndices.size } val totalRunning = books.values.sumOf { it.runningIndices.size }
val totalPaused = books.values.sumOf { it.pausedIndices.size }
val totalFailure = books.values.sumOf { it.failedIndices.size } + val totalFailure = books.values.sumOf { it.failedIndices.size } +
books.values.count { it.failureMessage != null } books.values.count { it.failureMessage != null }
val totalSuccess = books.values.sumOf { it.successCount } val totalSuccess = books.values.sumOf { it.successCount }
return copy( return copy(
isRunning = totalWaiting > 0 || totalRunning > 0, isRunning = totalWaiting > totalPaused || totalRunning > 0,
totalWaiting = totalWaiting, totalWaiting = totalWaiting,
totalRunning = totalRunning, totalRunning = totalRunning,
totalPaused = totalPaused,
totalFailure = totalFailure, totalFailure = totalFailure,
totalSuccess = totalSuccess, totalSuccess = totalSuccess,
) )
@@ -12,16 +12,25 @@ import io.legado.app.constant.NotificationId
import io.legado.app.data.appDb import io.legado.app.data.appDb
import io.legado.app.help.book.update import io.legado.app.help.book.update
import io.legado.app.model.CacheBook import io.legado.app.model.CacheBook
import io.legado.app.model.cache.CacheDownloadAdmissionQueue
import io.legado.app.model.cache.CacheDownloadRequest import io.legado.app.model.cache.CacheDownloadRequest
import io.legado.app.model.cache.CacheDownloadSource import io.legado.app.model.cache.CacheDownloadSource
import io.legado.app.model.cache.ChapterSelection import io.legado.app.model.cache.ChapterSelection
import io.legado.app.model.webBook.WebBook import io.legado.app.model.webBook.WebBook
import io.legado.app.ui.config.otherConfig.OtherConfig import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.ui.main.MainActivity import io.legado.app.ui.main.MainActivity
import io.legado.app.utils.LogUtils
import io.legado.app.utils.activityPendingIntent import io.legado.app.utils.activityPendingIntent
import io.legado.app.utils.servicePendingIntent import io.legado.app.utils.servicePendingIntent
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExecutorCoroutineDispatcher
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -38,16 +47,29 @@ import kotlin.math.min
class CacheBookService : BaseService() { class CacheBookService : BaseService() {
companion object { companion object {
private const val MB = 1024L * 1024L
private const val DIAGNOSTICS_LOG_INTERVAL_MILLIS = 5_000L
var isRun = false var isRun = false
private set private set
} }
private val threadCount = OtherConfig.cacheBookThreadCount.coerceIn(1, CacheBook.maxDownloadConcurrency) private val threadCount = OtherConfig.cacheBookThreadCount.coerceIn(1, CacheBook.maxDownloadConcurrency)
private var cachePool = private val maxActiveBookCount = (threadCount * 2).coerceAtLeast(1)
Executors.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher() private val admissionQueue = CacheDownloadAdmissionQueue(maxActiveBookCount)
private val admissionLock = Any()
private val admittingBookUrls = hashSetOf<String>()
private val admissionGenerations = hashMapOf<String, Long>()
private val admissionBuffers = hashMapOf<String, ArrayDeque<AdmissionRequest>>()
private val admissionIdleWaiters = hashMapOf<String, MutableList<CompletableDeferred<Unit>>>()
private lateinit var cachePool: ExecutorCoroutineDispatcher
private val serviceCommandScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val serviceCommandMutex = Mutex()
private val downloadJobLock = Any()
private var downloadJob: Job? = null private var downloadJob: Job? = null
private var notificationContent = appCtx.getString(R.string.service_starting) private var notificationContent = appCtx.getString(R.string.service_starting)
private var mutex = Mutex() private var mutex = Mutex()
private var lastDiagnosticsLogTime = 0L
private val notificationBuilder by lazy { private val notificationBuilder by lazy {
val builder = NotificationCompat.Builder(this, AppConst.channelIdDownload) val builder = NotificationCompat.Builder(this, AppConst.channelIdDownload)
.setSmallIcon(R.drawable.ic_download) .setSmallIcon(R.drawable.ic_download)
@@ -68,12 +90,24 @@ class CacheBookService : BaseService() {
builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
} }
private data class AdmissionRequest(
val request: CacheDownloadRequest,
val fromAdmissionQueue: Boolean,
val generation: Long,
)
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
if (::cachePool.isInitialized) {
cachePool.close()
}
cachePool = Executors.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher()
isRun = true isRun = true
lifecycleScope.launch { lifecycleScope.launch(Dispatchers.IO) {
while (isActive) { while (currentCoroutineContext().isActive) {
delay(1000) delay(1000)
drainPendingDownloadRequests()
logDownloadDiagnostics()
notificationContent = CacheBook.downloadSummary notificationContent = CacheBook.downloadSummary
upCacheBookNotification() upCacheBookNotification()
} }
@@ -81,119 +115,361 @@ class CacheBookService : BaseService() {
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
var startResult = START_REDELIVER_INTENT
intent?.action?.let { action -> intent?.action?.let { action ->
when (action) { when (action) {
IntentAction.start -> { IntentAction.start -> {
val requestId = intent.getLongExtra("requestId", -1L) val request = reconstructRequestFromIntent(intent) ?: run {
val request = if (requestId >= 0) { stopIfIdle()
CacheBook.takePendingRequest(requestId) return@let
} else {
null
}
if (request != null) {
addDownloadRequest(request)
} else {
val bookUrl = intent.getStringExtra("bookUrl") ?: return@let
addDownloadData(
bookUrl,
intent.getIntExtra("start", 0),
intent.getIntExtra("end", 0)
)
} }
addDownloadRequest(request)
} }
IntentAction.remove -> { IntentAction.remove -> {
val bookUrl = intent.getStringExtra("bookUrl") val bookUrl = intent.getStringExtra("bookUrl")
bookUrl?.let { CacheBook.removeBook(it) } val removeRequestId = intent.getLongExtra("removeRequestId", -1L)
bookUrl?.let {
lifecycleScope.launch(Dispatchers.IO) {
val removed = removeBookCompletely(it)
if (removeRequestId >= 0L) {
CacheBook.completePendingRemoveRequest(removeRequestId, removed)
}
stopIfIdle()
}
}
}
IntentAction.stop -> {
startResult = START_NOT_STICKY
stopSelf()
}
IntentAction.pause -> {
serviceCommandScope.launch {
serviceCommandMutex.withLock {
CacheBook.pauseAllFromService()
notificationContent = CacheBook.downloadSummary
upCacheBookNotification()
}
}
}
IntentAction.resume -> {
serviceCommandScope.launch {
serviceCommandMutex.withLock {
CacheBook.resumeFromService()
ensureDownloadJob()
notificationContent = CacheBook.downloadSummary
upCacheBookNotification()
}
}
} }
IntentAction.stop -> stopSelf()
} }
} }
return super.onStartCommand(intent, flags, startId) super.onStartCommand(intent, flags, startId)
return startResult
} }
override fun onDestroy() { override fun onDestroy() {
isRun = false isRun = false
cachePool.close() if (::cachePool.isInitialized) {
CacheBook.close() cachePool.close()
}
synchronized(admissionQueue) {
admissionQueue.clear()
}
synchronized(admissionLock) {
admissionBuffers.clear()
admittingBookUrls.clear()
admissionIdleWaiters.values.flatten().forEach { it.complete(Unit) }
admissionIdleWaiters.clear()
}
serviceCommandScope.launch {
try {
serviceCommandMutex.withLock {
CacheBook.close(clearFailureState = false)
}
} finally {
serviceCommandScope.cancel()
}
}
super.onDestroy() super.onDestroy()
} }
private fun addDownloadData(bookUrl: String?, start: Int, end: Int) { private fun reconstructRequestFromIntent(intent: Intent): CacheDownloadRequest? {
bookUrl ?: return val bookUrl = intent.getStringExtra("bookUrl") ?: return null
if (end < start) return val sourceName = intent.getStringExtra("source")
addDownloadRequest( val source = sourceName?.let { name ->
CacheDownloadRequest( runCatching { CacheDownloadSource.valueOf(name) }.getOrDefault(CacheDownloadSource.Manual)
} ?: CacheDownloadSource.Manual
val indices = intent.getIntArrayExtra("indices")
if (indices != null && indices.isNotEmpty()) {
return CacheDownloadRequest(
bookUrl = bookUrl, bookUrl = bookUrl,
selection = ChapterSelection.Range(start, end), selection = ChapterSelection.Indices(indices.toSet()),
source = CacheDownloadSource.Manual, source = source,
) )
}
val start = intent.getIntExtra("start", 0)
val end = intent.getIntExtra("end", 0)
if (end < start) return null
return CacheDownloadRequest(
bookUrl = bookUrl,
selection = ChapterSelection.Range(start, end),
source = source,
) )
} }
private fun addDownloadRequest(request: CacheDownloadRequest) { private fun addDownloadRequest(request: CacheDownloadRequest) {
execute { addDownloadRequestsToQueue(listOf(request))
val cacheBook = CacheBook.getOrCreate(request.bookUrl) ?: run { }
CacheBook.markBookFailed(request.bookUrl, getString(R.string.error_no_source))
return@execute 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 ->
submitDownloadRequest(request, fromAdmissionQueue = false)
}
}
val book = cacheBook.book private fun submitDownloadRequest(
val chapterCount = appDb.bookChapterDao.getChapterCount(request.bookUrl) request: CacheDownloadRequest,
fromAdmissionQueue: Boolean,
) {
val shouldStart = synchronized(admissionLock) {
val generation = admissionGenerations[request.bookUrl] ?: 0L
admissionBuffers.getOrPut(request.bookUrl) { ArrayDeque() }
.addLast(AdmissionRequest(request, fromAdmissionQueue, generation))
admittingBookUrls.add(request.bookUrl)
}
if (shouldStart) {
startAdmissionJob(request.bookUrl)
}
}
if (chapterCount == 0) { private fun startAdmissionJob(bookUrl: String) {
cacheBook.setLoading() execute(executeContext = Dispatchers.IO) {
mutex.withLock { while (currentCoroutineContext().isActive) {
val name = book.name val admission = nextAdmissionRequest(bookUrl) ?: return@execute
if (book.tocUrl.isEmpty()) { processAdmissionRequest(admission)
kotlin.runCatching { }
WebBook.getBookInfoAwait(cacheBook.bookSource, book) }.onFinally {
}.onFailure { finishAdmissionJob(bookUrl)
removeDownload(request.bookUrl) drainPendingDownloadRequests()
CacheBook.markBookFailed( ensureDownloadJob()
request.bookUrl, }
getString(R.string.error_get_book_info) }
)
AppLog.put(
"$name》目录为空且加载详情页失败\n${it.localizedMessage}",
it,
true
)
return@execute
}
}
WebBook.getChapterListAwait(cacheBook.bookSource, book).onFailure { private fun nextAdmissionRequest(bookUrl: String): AdmissionRequest? {
if (book.totalChapterNum > 0) { return synchronized(admissionLock) {
book.totalChapterNum = 0 val buffer = admissionBuffers[bookUrl] ?: return@synchronized null
book.update() if (buffer.isEmpty()) return@synchronized null
} buffer.removeFirst()
}
}
private suspend fun processAdmissionRequest(admission: AdmissionRequest) {
val request = admission.request
if (!admission.isCurrent()) return
val cacheBook = CacheBook.getOrCreate(request.bookUrl) ?: run {
markBookAdmissionFailed(request.bookUrl, getString(R.string.error_no_source))
return
}
if (!admission.isCurrent()) {
CacheBook.removeModelFromService(request.bookUrl, cacheBook)
return
}
val book = cacheBook.book
val chapterCount = appDb.bookChapterDao.getChapterCount(request.bookUrl)
if (chapterCount == 0) {
cacheBook.setLoading()
mutex.withLock {
val name = book.name
if (!admission.isCurrent()) {
CacheBook.removeModelFromService(request.bookUrl, cacheBook)
return
}
if (book.tocUrl.isEmpty()) {
kotlin.runCatching {
WebBook.getBookInfoAwait(cacheBook.bookSource, book)
}.onFailure {
removeDownload(request.bookUrl) removeDownload(request.bookUrl)
CacheBook.markBookFailed( markBookAdmissionFailed(
request.bookUrl, request.bookUrl,
getString(R.string.error_get_chapter_list) getString(R.string.error_get_book_info)
) )
AppLog.put( AppLog.put(
"$name》目录为空且加载目录失败\n${it.localizedMessage}", "$name》目录为空且加载详情页失败\n${it.localizedMessage}",
it, it,
true true
) )
return@execute return
}.getOrNull()?.let { toc ->
appDb.bookChapterDao.insert(*toc.toTypedArray())
} }
}
book.update() if (!admission.isCurrent()) {
CacheBook.removeModelFromService(request.bookUrl, cacheBook)
return
}
WebBook.getChapterListAwait(cacheBook.bookSource, book).onFailure {
if (book.totalChapterNum > 0) {
book.totalChapterNum = 0
book.update()
}
removeDownload(request.bookUrl)
markBookAdmissionFailed(
request.bookUrl,
getString(R.string.error_get_chapter_list)
)
AppLog.put(
"$name》目录为空且加载目录失败\n${it.localizedMessage}",
it,
true
)
return
}.getOrNull()?.let { toc ->
appDb.bookChapterDao.insert(*toc.toTypedArray())
}
book.update()
}
}
if (!admission.isCurrent()) {
CacheBook.removeModelFromService(request.bookUrl, cacheBook)
return
}
//添加章节到下载队列
cacheBook.addRequest(request)
if (admission.fromAdmissionQueue) {
CacheBook.removePendingAdmission(request)
}
notificationContent = CacheBook.downloadSummary
upCacheBookNotification()
}
private fun AdmissionRequest.isCurrent(): Boolean {
return synchronized(admissionLock) {
admissionGenerations[request.bookUrl].orZero() == generation
}
}
private fun admittedBookUrls(): Set<String> {
return CacheBook.cacheBookMap.keys.toHashSet().apply {
synchronized(admissionLock) {
addAll(admittingBookUrls)
}
}
}
private fun hasPendingDownloadRequests(): Boolean {
return synchronized(admissionQueue) {
!admissionQueue.isEmpty()
}
}
private fun hasAdmittingRequests(): Boolean {
return synchronized(admissionLock) {
admittingBookUrls.isNotEmpty()
}
}
private fun removeQueuedBook(bookUrl: String): Boolean {
return synchronized(admissionQueue) {
admissionQueue.removeBook(bookUrl)
}
}
private suspend fun removeBookCompletely(bookUrl: String): Boolean {
val removedQueued = removeQueuedBook(bookUrl)
val removedAdmission = cancelAdmission(bookUrl)
val removedActive = CacheBook.removeBookFromService(bookUrl)
waitAdmissionIdle(bookUrl)
return removedQueued || removedAdmission || removedActive
}
private fun markBookAdmissionFailed(bookUrl: String, message: String) {
removeQueuedBook(bookUrl)
CacheBook.markBookFailed(bookUrl, message)
}
private fun cancelAdmission(bookUrl: String): Boolean {
return synchronized(admissionLock) {
admissionGenerations[bookUrl] = admissionGenerations[bookUrl].orZero() + 1L
val removedBuffered = admissionBuffers.remove(bookUrl)?.isNotEmpty() == true
removedBuffered || bookUrl in admittingBookUrls
}
}
private suspend fun waitAdmissionIdle(bookUrl: String) {
val waiter = synchronized(admissionLock) {
if (bookUrl !in admittingBookUrls) {
null
} else {
CompletableDeferred<Unit>().also {
admissionIdleWaiters.getOrPut(bookUrl) { mutableListOf() }.add(it)
} }
} }
}
waiter?.await()
}
//添加章节到下载队列 private fun finishAdmissionJob(bookUrl: String) {
cacheBook.addRequest(request) var restart = false
val waiters = synchronized(admissionLock) {
val buffer = admissionBuffers[bookUrl]
if (buffer != null && buffer.isNotEmpty()) {
restart = true
emptyList()
} else {
admissionBuffers.remove(bookUrl)
admittingBookUrls.remove(bookUrl)
admissionIdleWaiters.remove(bookUrl).orEmpty()
}
}
if (restart) {
startAdmissionJob(bookUrl)
return
}
waiters.forEach { it.complete(Unit) }
}
notificationContent = CacheBook.downloadSummary private fun drainPendingDownloadRequests() {
upCacheBookNotification() while (true) {
}.onFinally { val request = synchronized(admissionQueue) {
if (downloadJob == null) { admissionQueue.pollReady(admittedBookUrls())
download() } ?: return
submitDownloadRequest(request, fromAdmissionQueue = true)
}
}
private fun ensureDownloadJob() {
synchronized(downloadJobLock) {
if (downloadJob?.isActive == true) return
downloadJob = lifecycleScope.launch(cachePool) {
runDownloadLoop()
} }
} }
} }
@@ -201,19 +477,41 @@ class CacheBookService : BaseService() {
private fun removeDownload(bookUrl: String?) { private fun removeDownload(bookUrl: String?) {
CacheBook.cacheBookMap[bookUrl]?.stop() CacheBook.cacheBookMap[bookUrl]?.stop()
if (downloadJob == null && CacheBook.isRun) { if (CacheBook.isRun) {
download() ensureDownloadJob()
return return
} }
if (CacheBook.cacheBookMap.isEmpty()) { stopIfIdle()
stopSelf() }
private suspend fun runDownloadLoop() {
try {
while (currentCoroutineContext().isActive) {
drainPendingDownloadRequests()
if (CacheBook.isGloballyPaused) {
delay(200)
continue
}
if (!CacheBook.isRun) {
if (!hasPendingDownloadRequests() && !hasAdmittingRequests()) break
delay(200)
continue
}
CacheBook.startProcessJob(cachePool)
}
} finally {
val finishedJob = currentCoroutineContext()[Job]
synchronized(downloadJobLock) {
if (downloadJob == finishedJob) {
downloadJob = null
}
}
stopIfIdle()
} }
} }
private fun download() { private fun stopIfIdle() {
downloadJob?.cancel() if (!CacheBook.isRun && !hasPendingDownloadRequests() && !hasAdmittingRequests()) {
downloadJob = lifecycleScope.launch(cachePool) {
CacheBook.startProcessJob(cachePool)
stopSelf() stopSelf()
} }
} }
@@ -221,7 +519,12 @@ class CacheBookService : BaseService() {
private fun upCacheBookNotification() { private fun upCacheBookNotification() {
val total = CacheBook.totalCount val total = CacheBook.totalCount
val progress = CacheBook.completedCount val progress = CacheBook.completedCount
val summary = CacheBook.downloadSummary val pendingBookCount = synchronized(admissionQueue) { admissionQueue.size }
val summary = if (pendingBookCount > 0) {
"${CacheBook.downloadSummary} | 待入队:$pendingBookCount"
} else {
CacheBook.downloadSummary
}
notificationBuilder.apply { notificationBuilder.apply {
setContentText(summary) setContentText(summary)
@@ -235,6 +538,41 @@ class CacheBookService : BaseService() {
notificationManager.notify(NotificationId.CacheBookService, notificationBuilder.build()) notificationManager.notify(NotificationId.CacheBookService, notificationBuilder.build())
} }
private fun logDownloadDiagnostics() {
val now = System.currentTimeMillis()
if (now - lastDiagnosticsLogTime < DIAGNOSTICS_LOG_INTERVAL_MILLIS) return
lastDiagnosticsLogTime = now
val pendingBookCount = synchronized(admissionQueue) { admissionQueue.size }
if (!CacheBook.isRun && pendingBookCount == 0 && !hasAdmittingRequests()) return
val diagnostics = CacheBook.diagnostics()
val runtime = Runtime.getRuntime()
val usedMemoryMb = (runtime.totalMemory() - runtime.freeMemory()) / MB
val totalMemoryMb = runtime.totalMemory() / MB
val maxMemoryMb = runtime.maxMemory() / MB
LogUtils.d("CacheBookDiagnostics") {
"activeBooks=${diagnostics.activeBookCount}, " +
"admittingBooks=${admittingBookCount()}, " +
"pendingBooks=$pendingBookCount, " +
"waitingChapters=${diagnostics.waitingChapterCount}, " +
"runningChapters=${diagnostics.runningChapterCount}, " +
"chapterTasks=${diagnostics.trackedChapterTaskCount}, " +
"loadingBooks=${diagnostics.loadingBookCount}, " +
"retryingBooks=${diagnostics.retryingBookCount}, " +
"configuredThreads=$threadCount, " +
"heap=${usedMemoryMb}MB/${totalMemoryMb}MB max=${maxMemoryMb}MB"
}
}
private fun admittingBookCount(): Int {
return synchronized(admissionLock) {
admittingBookUrls.size
}
}
private fun Long?.orZero(): Long = this ?: 0L
/** /**
* 更新通知 * 更新通知
*/ */
@@ -96,7 +96,7 @@ private fun BookCacheManageScreen(
var pendingDeleteBook by remember { mutableStateOf<BookCacheBookItem?>(null) } var pendingDeleteBook by remember { mutableStateOf<BookCacheBookItem?>(null) }
var pendingDeleteChapter by remember { mutableStateOf<Pair<BookCacheBookItem, BookCacheChapterItem>?>(null) } var pendingDeleteChapter by remember { mutableStateOf<Pair<BookCacheBookItem, BookCacheChapterItem>?>(null) }
val allBooks = state.shelfBooks + state.notShelfBooks val allBooks = state.shelfBooks + state.notShelfBooks
val hasRunningDownload = allBooks.any { it.isDownloading } val hasRunningDownload = allBooks.any { it.hasActiveDownload }
val hasDownloadTarget = allBooks.any { it.cachedCount < it.totalCount } val hasDownloadTarget = allBooks.any { it.cachedCount < it.totalCount }
AppScaffold( AppScaffold(
@@ -351,22 +351,26 @@ private fun BookCacheBookCard(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
AppText( AppText(
text = "下载中 ${item.downloadingCount} · 等待 ${item.waitingCount} · 失败 ${item.errorCount}", text = "下载中 ${item.downloadingCount} · 等待 ${item.waitingCount} · 暂停 ${item.pausedCount} · 失败 ${item.errorCount}",
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
style = LegadoTheme.typography.labelMediumEmphasized, style = LegadoTheme.typography.labelMediumEmphasized,
color = LegadoTheme.colorScheme.onSurfaceVariant color = LegadoTheme.colorScheme.onSurfaceVariant
) )
if (item.isDownloading || item.cachedCount < item.totalCount) { if (item.hasDownloadTask || item.cachedCount < item.totalCount) {
SmallTonalIconButton( SmallTonalIconButton(
onClick = { onClick = {
if (item.isDownloading) { if (item.hasActiveDownload) {
onIntent(BookCacheManageIntent.StopBookDownload(item.bookUrl)) onIntent(BookCacheManageIntent.StopBookDownload(item.bookUrl))
} else { } else {
onIntent(BookCacheManageIntent.StartBookDownload(item.bookUrl)) onIntent(BookCacheManageIntent.StartBookDownload(item.bookUrl))
} }
}, },
imageVector = if (item.isDownloading) Icons.Default.Stop else Icons.Default.PlayArrow, imageVector = if (item.hasActiveDownload) Icons.Default.Stop else Icons.Default.PlayArrow,
contentDescription = if (item.isDownloading) "暂停本书下载" else "开始本书下载" contentDescription = when {
item.hasActiveDownload -> "暂停本书下载"
item.isPaused -> "继续本书下载"
else -> "开始本书下载"
}
) )
} }
SmallTonalIconButton( SmallTonalIconButton(
@@ -423,11 +427,11 @@ private fun BookCacheChapterRow(
imageVector = Icons.Default.Stop, imageVector = Icons.Default.Stop,
contentDescription = "暂停章节下载" contentDescription = "暂停章节下载"
) )
} else if (!item.isCached) { } else if (item.isPaused || !item.isCached) {
SmallTonalIconButton( SmallTonalIconButton(
onClick = onDownload, onClick = onDownload,
imageVector = Icons.Default.Download, imageVector = Icons.Default.Download,
contentDescription = "下载章节" contentDescription = if (item.isPaused) "继续章节下载" else "下载章节"
) )
} }
SmallTonalIconButton( SmallTonalIconButton(
@@ -442,6 +446,7 @@ private fun chapterStatusText(item: BookCacheChapterItem): String {
return when { return when {
item.isDownloading -> "下载中" item.isDownloading -> "下载中"
item.isWaiting -> "等待下载" item.isWaiting -> "等待下载"
item.isPaused -> "已暂停"
item.isError -> "下载失败" item.isError -> "下载失败"
item.isCached -> "已缓存" item.isCached -> "已缓存"
else -> "未缓存" else -> "未缓存"
@@ -38,6 +38,7 @@ data class BookCacheManageUiState(
val expandedBookUrls: Set<String> = emptySet(), val expandedBookUrls: Set<String> = emptySet(),
val chaptersByBookUrl: Map<String, List<BookCacheChapterItem>> = emptyMap(), val chaptersByBookUrl: Map<String, List<BookCacheChapterItem>> = emptyMap(),
val downloadSummary: String = "", val downloadSummary: String = "",
val hasPausedDownloads: Boolean = false,
val version: Long = 0, val version: Long = 0,
) )
@@ -50,11 +51,15 @@ data class BookCacheBookItem(
val cachedFileCount: Int, val cachedFileCount: Int,
val waitingCount: Int, val waitingCount: Int,
val downloadingCount: Int, val downloadingCount: Int,
val pausedCount: Int,
val errorCount: Int, val errorCount: Int,
val isNotShelf: Boolean, val isNotShelf: Boolean,
) { ) {
val progress: Float get() = if (totalCount == 0) 0f else cachedCount.toFloat() / totalCount val progress: Float get() = if (totalCount == 0) 0f else cachedCount.toFloat() / totalCount
val isDownloading: Boolean get() = waitingCount > 0 || downloadingCount > 0 val hasActiveDownload: Boolean get() = waitingCount > 0 || downloadingCount > 0
val isPaused: Boolean get() = pausedCount > 0
val hasDownloadTask: Boolean get() = hasActiveDownload || isPaused
val isDownloading: Boolean get() = hasActiveDownload
} }
data class BookCacheChapterItem( data class BookCacheChapterItem(
@@ -64,6 +69,7 @@ data class BookCacheChapterItem(
val isCached: Boolean, val isCached: Boolean,
val isWaiting: Boolean, val isWaiting: Boolean,
val isDownloading: Boolean, val isDownloading: Boolean,
val isPaused: Boolean,
val isError: Boolean, val isError: Boolean,
) )
@@ -161,6 +167,12 @@ class BookCacheManageViewModel(
pendingDownloadSummaryRefresh = true pendingDownloadSummaryRefresh = true
} }
} }
viewModelScope.launch {
CacheBook.pendingAdmissionFlow.collect { pending ->
pending.keys.forEach { scheduleDownloadStatusRefresh(it) }
pendingDownloadSummaryRefresh = true
}
}
viewModelScope.launch { viewModelScope.launch {
CacheBook.queueChangedFlow.collect { bookUrl -> CacheBook.queueChangedFlow.collect { bookUrl ->
scheduleDownloadStatusRefresh(bookUrl) scheduleDownloadStatusRefresh(bookUrl)
@@ -212,6 +224,7 @@ class BookCacheManageViewModel(
expandedBookUrls = result.expandedBookUrls, expandedBookUrls = result.expandedBookUrls,
chaptersByBookUrl = result.chaptersByBookUrl, chaptersByBookUrl = result.chaptersByBookUrl,
downloadSummary = buildDownloadSummary(result.items), downloadSummary = buildDownloadSummary(result.items),
hasPausedDownloads = CacheBook.hasPausedDownloads,
version = state.version + 1, version = state.version + 1,
) )
} }
@@ -237,6 +250,7 @@ class BookCacheManageViewModel(
_uiState.update { _uiState.update {
it.copy( it.copy(
downloadSummary = buildDownloadSummary(it.shelfBooks + it.notShelfBooks), downloadSummary = buildDownloadSummary(it.shelfBooks + it.notShelfBooks),
hasPausedDownloads = CacheBook.hasPausedDownloads,
version = it.version + 1, version = it.version + 1,
) )
} }
@@ -295,6 +309,7 @@ class BookCacheManageViewModel(
expandedBookUrls = expandedBookUrls, expandedBookUrls = expandedBookUrls,
chaptersByBookUrl = chaptersByBookUrl, chaptersByBookUrl = chaptersByBookUrl,
downloadSummary = buildDownloadSummary(sortedBooks), downloadSummary = buildDownloadSummary(sortedBooks),
hasPausedDownloads = CacheBook.hasPausedDownloads,
version = state.version + 1, version = state.version + 1,
) )
} }
@@ -302,13 +317,25 @@ class BookCacheManageViewModel(
private fun buildBookItem(book: Book): BookCacheBookItem? { private fun buildBookItem(book: Book): BookCacheBookItem? {
val cacheFiles = BookHelp.getChapterFiles(book) val cacheFiles = BookHelp.getChapterFiles(book)
val bookState = CacheBook.downloadStateFlow.value.books[book.bookUrl]
val model = CacheBook.cacheBookMap[book.bookUrl] val model = CacheBook.cacheBookMap[book.bookUrl]
val (waitingCount, downloadingCount) = model?.queueCounts() ?: (0 to 0) val rawWaitingCount = bookState?.waitingCount.orZero() +
CacheBook.pendingAdmissionFlow.value[book.bookUrl].orZero()
val rawDownloadingCount = bookState?.runningIndices?.size.orZero()
val isBookPaused = model?.isPaused() == true ||
(CacheBook.hasPausedDownloads && CacheBook.pendingAdmissionFlow.value.containsKey(book.bookUrl))
val pausedCount = if (isBookPaused) {
rawWaitingCount + rawDownloadingCount + bookState?.pausedIndices?.size.orZero()
} else {
bookState?.pausedIndices?.size.orZero()
}
val waitingCount = if (isBookPaused) 0 else rawWaitingCount
val downloadingCount = if (isBookPaused) 0 else rawDownloadingCount
val errorIndices = errorIndices(book.bookUrl) val errorIndices = errorIndices(book.bookUrl)
val totalCount = bookChapterDao.getChapterCount(book.bookUrl) val totalCount = bookChapterDao.getChapterCount(book.bookUrl)
val cachedFileCount = cacheFiles.count { it.endsWith(".nb") } val cachedFileCount = cacheFiles.count { it.endsWith(".nb") }
val cachedCount = min(cachedFileCount + bookChapterDao.getVolumeCount(book.bookUrl), totalCount) val cachedCount = min(cachedFileCount + bookChapterDao.getVolumeCount(book.bookUrl), totalCount)
if (totalCount == 0 && cacheFiles.isEmpty() && model == null && !book.isNotShelf) { if (totalCount == 0 && cacheFiles.isEmpty() && waitingCount == 0 && downloadingCount == 0 && pausedCount == 0 && !book.isNotShelf) {
return null return null
} }
return BookCacheBookItem( return BookCacheBookItem(
@@ -320,13 +347,14 @@ class BookCacheManageViewModel(
cachedFileCount = cachedFileCount, cachedFileCount = cachedFileCount,
waitingCount = waitingCount, waitingCount = waitingCount,
downloadingCount = downloadingCount, downloadingCount = downloadingCount,
pausedCount = pausedCount,
errorCount = errorIndices.size, errorCount = errorIndices.size,
isNotShelf = book.isNotShelf, isNotShelf = book.isNotShelf,
) )
} }
private fun shouldShowItem(item: BookCacheBookItem): Boolean { private fun shouldShowItem(item: BookCacheBookItem): Boolean {
return item.cachedFileCount > 0 || item.isDownloading || item.errorCount > 0 return item.cachedFileCount > 0 || item.hasDownloadTask || item.errorCount > 0
} }
private fun buildChapterItems(bookUrl: String): List<BookCacheChapterItem> { private fun buildChapterItems(bookUrl: String): List<BookCacheChapterItem> {
@@ -336,13 +364,15 @@ class BookCacheManageViewModel(
val model = CacheBook.cacheBookMap[bookUrl] val model = CacheBook.cacheBookMap[bookUrl]
val errorIndices = errorIndices(bookUrl) val errorIndices = errorIndices(bookUrl)
return chapters.map { chapter -> return chapters.map { chapter ->
val isPaused = model?.isPaused(chapter.index) == true
BookCacheChapterItem( BookCacheChapterItem(
chapterUrl = chapter.url, chapterUrl = chapter.url,
title = chapter.title, title = chapter.title,
index = chapter.index, index = chapter.index,
isCached = cacheFiles.contains(chapter.getFileName()) || chapter.isVolume, isCached = cacheFiles.contains(chapter.getFileName()) || chapter.isVolume,
isWaiting = model?.isWaiting(chapter.index) == true, isWaiting = !isPaused && model?.isWaiting(chapter.index) == true,
isDownloading = model?.isDownloading(chapter.index) == true, isDownloading = !isPaused && model?.isDownloading(chapter.index) == true,
isPaused = isPaused,
isError = errorIndices.contains(chapter.index), isError = errorIndices.contains(chapter.index),
) )
} }
@@ -352,6 +382,8 @@ class BookCacheManageViewModel(
return CacheBook.errorIndices(bookUrl) return CacheBook.errorIndices(bookUrl)
} }
private fun Int?.orZero(): Int = this ?: 0
private fun toggleBookExpanded(bookUrl: String) { private fun toggleBookExpanded(bookUrl: String) {
val shouldExpand = !_uiState.value.expandedBookUrls.contains(bookUrl) val shouldExpand = !_uiState.value.expandedBookUrls.contains(bookUrl)
_uiState.update { state -> _uiState.update { state ->
@@ -392,18 +424,27 @@ class BookCacheManageViewModel(
} }
private fun stopAllDownloads() { private fun stopAllDownloads() {
CacheBook.stop(context) execute {
reloadAll(forceDatabase = true) CacheBook.pause(context)
}.onFinally {
reloadAll(forceDatabase = true)
}
} }
private fun stopBookDownload(bookUrl: String) { private fun stopBookDownload(bookUrl: String) {
CacheBook.removeBook(bookUrl) execute {
scheduleBookReload(bookUrl, debounceMillis = 0) CacheBook.pauseBook(context, bookUrl)
}.onFinally {
scheduleBookReload(bookUrl, debounceMillis = 0)
}
} }
private fun startAllDownloads() { private fun startAllDownloads() {
val items = uiState.value.shelfBooks + uiState.value.notShelfBooks val items = uiState.value.shelfBooks + uiState.value.notShelfBooks
execute { execute {
if (CacheBook.resume(context)) {
return@execute null
}
var count = 0 var count = 0
items.forEach { item -> items.forEach { item ->
downloadableChapterIndexBatches(item.bookUrl).forEach { chapterIndices -> downloadableChapterIndexBatches(item.bookUrl).forEach { chapterIndices ->
@@ -412,7 +453,8 @@ class BookCacheManageViewModel(
currentCoroutineContext().ensureActive() currentCoroutineContext().ensureActive()
} }
count count
}.onSuccess { count -> }.onSuccess { countOrNull ->
val count = countOrNull ?: return@onSuccess
if (count > 0) { if (count > 0) {
_effects.tryEmit(BookCacheManageEffect.ShowMessage("已加入缓存队列: $count")) _effects.tryEmit(BookCacheManageEffect.ShowMessage("已加入缓存队列: $count"))
} else { } else {
@@ -427,12 +469,16 @@ class BookCacheManageViewModel(
private fun startBookDownload(bookUrl: String) { private fun startBookDownload(bookUrl: String) {
execute { execute {
if (CacheBook.resumeBook(context, bookUrl)) {
return@execute null
}
var count = 0 var count = 0
downloadableChapterIndexBatches(bookUrl).forEach { chapterIndices -> downloadableChapterIndexBatches(bookUrl).forEach { chapterIndices ->
count += cacheBookChaptersUseCase.execute(bookUrl, chapterIndices) count += cacheBookChaptersUseCase.execute(bookUrl, chapterIndices)
} }
count count
}.onSuccess { count -> }.onSuccess { countOrNull ->
val count = countOrNull ?: return@onSuccess
if (count > 0) { if (count > 0) {
_effects.tryEmit(BookCacheManageEffect.ShowMessage("已加入缓存队列: $count")) _effects.tryEmit(BookCacheManageEffect.ShowMessage("已加入缓存队列: $count"))
} else { } else {
@@ -457,6 +503,7 @@ class BookCacheManageViewModel(
if ( if (
chapter.isVolume || chapter.isVolume ||
cacheFiles.contains(chapter.getFileName()) || cacheFiles.contains(chapter.getFileName()) ||
model?.isPaused(chapter.index) == true ||
model?.isWaiting(chapter.index) == true || model?.isWaiting(chapter.index) == true ||
model?.isDownloading(chapter.index) == true model?.isDownloading(chapter.index) == true
) { ) {
@@ -474,8 +521,8 @@ class BookCacheManageViewModel(
} }
private fun deleteBookCache(bookUrl: String) { private fun deleteBookCache(bookUrl: String) {
CacheBook.removeBook(bookUrl)
execute { execute {
CacheBook.removeAwait(context, bookUrl)
clearBookCacheUseCase.execute(bookUrl) clearBookCacheUseCase.execute(bookUrl)
}.onSuccess { }.onSuccess {
_effects.tryEmit(BookCacheManageEffect.ShowMessage("缓存已删除")) _effects.tryEmit(BookCacheManageEffect.ShowMessage("缓存已删除"))
@@ -488,8 +535,13 @@ class BookCacheManageViewModel(
private fun downloadChapter(bookUrl: String, chapterIndex: Int) { private fun downloadChapter(bookUrl: String, chapterIndex: Int) {
execute { execute {
if (CacheBook.resumeChapter(context, bookUrl, chapterIndex)) {
return@execute false
}
cacheBookChaptersUseCase.execute(bookUrl, listOf(chapterIndex)) cacheBookChaptersUseCase.execute(bookUrl, listOf(chapterIndex))
}.onSuccess { true
}.onSuccess { enqueued ->
if (!enqueued) return@onSuccess
_effects.tryEmit(BookCacheManageEffect.ShowMessage("章节已加入缓存队列")) _effects.tryEmit(BookCacheManageEffect.ShowMessage("章节已加入缓存队列"))
}.onError { }.onError {
_effects.tryEmit(BookCacheManageEffect.ShowMessage("章节缓存失败\n${it.localizedMessage}")) _effects.tryEmit(BookCacheManageEffect.ShowMessage("章节缓存失败\n${it.localizedMessage}"))
@@ -499,7 +551,9 @@ class BookCacheManageViewModel(
} }
private fun stopChapterDownload(bookUrl: String, chapterIndex: Int) { private fun stopChapterDownload(bookUrl: String, chapterIndex: Int) {
if (CacheBook.removeChapter(bookUrl, chapterIndex)) { execute {
CacheBook.pauseChapter(bookUrl, chapterIndex)
}.onSuccess {
scheduleBookReload(bookUrl, debounceMillis = 0) scheduleBookReload(bookUrl, debounceMillis = 0)
} }
} }
@@ -510,16 +564,18 @@ class BookCacheManageViewModel(
chapterTitle: String, chapterTitle: String,
chapterIndex: Int, chapterIndex: Int,
) { ) {
val book = bookDao.getBook(bookUrl) ?: return
val chapter = BookChapter(
url = chapterUrl,
title = chapterTitle,
bookUrl = bookUrl,
index = chapterIndex,
)
execute { execute {
val book = bookDao.getBook(bookUrl) ?: return@execute false
val chapter = BookChapter(
url = chapterUrl,
title = chapterTitle,
bookUrl = bookUrl,
index = chapterIndex,
)
BookHelp.delContent(book, chapter) BookHelp.delContent(book, chapter)
}.onSuccess { true
}.onSuccess { deleted ->
if (!deleted) return@onSuccess
_effects.tryEmit(BookCacheManageEffect.ShowMessage("章节缓存已删除")) _effects.tryEmit(BookCacheManageEffect.ShowMessage("章节缓存已删除"))
}.onError { }.onError {
_effects.tryEmit(BookCacheManageEffect.ShowMessage("删除章节缓存失败\n${it.localizedMessage}")) _effects.tryEmit(BookCacheManageEffect.ShowMessage("删除章节缓存失败\n${it.localizedMessage}"))
@@ -538,9 +594,10 @@ class BookCacheManageViewModel(
if (items.isEmpty()) return "" if (items.isEmpty()) return ""
val downloadingCount = items.sumOf { it.downloadingCount } val downloadingCount = items.sumOf { it.downloadingCount }
val waitingCount = items.sumOf { it.waitingCount } val waitingCount = items.sumOf { it.waitingCount }
val pausedCount = items.sumOf { it.pausedCount }
val errorCount = items.sumOf { it.errorCount } val errorCount = items.sumOf { it.errorCount }
val cachedCount = items.sumOf { it.cachedCount } val cachedCount = items.sumOf { it.cachedCount }
return "正在下载:$downloadingCount | 等待:$waitingCount | 失败:$errorCount | 成功:$cachedCount" return "下载:$downloadingCount | 等待:$waitingCount | 暂停:$pausedCount | 失败:$errorCount | 已缓存:$cachedCount"
} }
private data class LoadedCacheState( private data class LoadedCacheState(
@@ -304,7 +304,9 @@ class BookshelfManageScreenViewModel(
fun getCacheCount(bookUrl: String): Int? = cacheCounts[bookUrl] fun getCacheCount(bookUrl: String): Int? = cacheCounts[bookUrl]
fun isBookDownloading(bookUrl: String): Boolean { fun isBookDownloading(bookUrl: String): Boolean {
return CacheBook.cacheBookMap[bookUrl]?.isStop() == false if (CacheBook.pendingAdmissionFlow.value[bookUrl].orZero() > 0) return true
val bookState = CacheBook.downloadStateFlow.value.books[bookUrl] ?: return false
return bookState.waitingCount > 0 || bookState.runningIndices.isNotEmpty()
} }
fun getDownloadFailureMessage(bookUrl: String): String? { fun getDownloadFailureMessage(bookUrl: String): String? {
@@ -409,13 +411,23 @@ class BookshelfManageScreenViewModel(
pendingDownloadBookUrls.removeAll(downloadState.books.keys) pendingDownloadBookUrls.removeAll(downloadState.books.keys)
successfulBookUrls.forEach { downloadFailureMessages.remove(it) } successfulBookUrls.forEach { downloadFailureMessages.remove(it) }
downloadFailureMessages.putAll(failureMsgs) downloadFailureMessages.putAll(failureMsgs)
_uiState.update { it.copy(isDownloadRunning = downloadState.isRunning) } _uiState.update {
it.copy(isDownloadRunning = downloadState.isRunning || CacheBook.pendingAdmissionFlow.value.isNotEmpty())
}
downloadState.books.keys.forEach { bookUrl -> downloadState.books.keys.forEach { bookUrl ->
scheduleDownloadStatusRefresh(bookUrl) scheduleDownloadStatusRefresh(bookUrl)
} }
scheduleDownloadStatusRefresh() scheduleDownloadStatusRefresh()
} }
} }
viewModelScope.launch {
CacheBook.pendingAdmissionFlow.collect { pending ->
pending.keys.forEach { bookUrl ->
scheduleDownloadStatusRefresh(bookUrl)
}
scheduleDownloadStatusRefresh()
}
}
viewModelScope.launch { viewModelScope.launch {
CacheBook.queueChangedFlow.collect { bookUrl -> CacheBook.queueChangedFlow.collect { bookUrl ->
scheduleDownloadStatusRefresh(bookUrl) scheduleDownloadStatusRefresh(bookUrl)
@@ -454,7 +466,9 @@ class BookshelfManageScreenViewModel(
} }
private fun syncDownloadRunning() { private fun syncDownloadRunning() {
_uiState.update { it.copy(isDownloadRunning = CacheBook.isRun) } _uiState.update {
it.copy(isDownloadRunning = CacheBook.isRun || CacheBook.pendingAdmissionFlow.value.isNotEmpty())
}
} }
private fun refreshGroupName(groupId: Long) { private fun refreshGroupName(groupId: Long) {
@@ -541,6 +555,8 @@ class BookshelfManageScreenViewModel(
return min(cachedFileCount + bookChapterDao.getVolumeCount(book.bookUrl), totalCount) return min(cachedFileCount + bookChapterDao.getVolumeCount(book.bookUrl), totalCount)
} }
private fun Int?.orZero(): Int = this ?: 0
private fun startDownloadForVisibleBooks(books: List<Book>, downloadAllChapters: Boolean) { private fun startDownloadForVisibleBooks(books: List<Book>, downloadAllChapters: Boolean) {
val bookUrls = books.mapTo(hashSetOf()) { it.bookUrl } val bookUrls = books.mapTo(hashSetOf()) { it.bookUrl }
bookUrls.forEach { downloadFailureMessages.remove(it) } bookUrls.forEach { downloadFailureMessages.remove(it) }
@@ -0,0 +1,56 @@
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 CacheDownloadAdmissionQueueTest {
@Test
fun queuesNewBookWhenActiveLimitIsReached() {
val queue = CacheDownloadAdmissionQueue(maxActiveBooks = 2)
val request = request("c")
assertTrue(queue.shouldQueue(request, setOf("a", "b")))
queue.add(request)
assertNull(queue.pollReady(setOf("a", "b")))
assertEquals(request, queue.pollReady(setOf("a")))
}
@Test
fun activeBookRequestCanBypassLimit() {
val queue = CacheDownloadAdmissionQueue(maxActiveBooks = 1)
val activeRequest = request("a")
queue.add(request("b"))
queue.add(activeRequest)
assertEquals(activeRequest, queue.pollReady(setOf("a")))
}
@Test
fun removeBookDropsPendingRequestsForBook() {
val queue = CacheDownloadAdmissionQueue(maxActiveBooks = 1)
queue.add(request("a"))
queue.add(request("b"))
queue.add(request("a"))
assertTrue(queue.removeBook("a"))
assertFalse(queue.removeBook("a"))
assertEquals(request("b"), queue.pollReady(emptySet()))
assertNull(queue.pollReady(emptySet()))
}
private fun request(bookUrl: String): CacheDownloadRequest {
return CacheDownloadRequest(
bookUrl = bookUrl,
selection = ChapterSelection.Single(0),
source = CacheDownloadSource.Batch,
)
}
}
@@ -63,4 +63,24 @@ class CacheDownloadStateStoreTest {
assertNull(store.state.books.getValue("a").failureMessage) assertNull(store.state.books.getValue("a").failureMessage)
assertEquals(0, store.state.totalFailure) assertEquals(0, store.state.totalFailure)
} }
@Test
fun clearRuntimeStateKeepsOnlyVisibleFailures() {
val store = CacheDownloadStateStore()
store.updateBookQueue("running", waitingCount = 2, runningIndices = setOf(1))
store.updateBookQueue("failed", waitingCount = 1, runningIndices = setOf(2))
store.markFailed("failed", 2)
store.markBookFailed("bookFailure", "source unavailable")
store.clearRuntimeState()
assertFalse(store.state.isRunning)
assertEquals(setOf("failed", "bookFailure"), store.state.books.keys)
assertEquals(setOf(2), store.state.books.getValue("failed").failedIndices)
assertEquals("source unavailable", store.state.books.getValue("bookFailure").failureMessage)
assertEquals(2, store.state.totalFailure)
assertEquals(0, store.state.totalWaiting)
assertEquals(0, store.state.totalRunning)
}
} }