[优化] 继续干下载逻辑,现在小米设备应该大概可能不会再有主线程锁等待导致的ANR
This commit is contained in:
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新通知
|
* 更新通知
|
||||||
*/
|
*/
|
||||||
|
|||||||
+13
-8
@@ -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 -> "未缓存"
|
||||||
|
|||||||
+81
-24
@@ -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) }
|
||||||
|
|||||||
+56
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user