[优化] 重写下载核心
This commit is contained in:
@@ -2,15 +2,36 @@ package io.legado.app.data.repository
|
|||||||
|
|
||||||
import io.legado.app.data.dao.BookDao
|
import io.legado.app.data.dao.BookDao
|
||||||
import io.legado.app.domain.gateway.BookCacheDownloadGateway
|
import io.legado.app.domain.gateway.BookCacheDownloadGateway
|
||||||
|
import io.legado.app.help.book.isLocal
|
||||||
import io.legado.app.model.CacheBook
|
import io.legado.app.model.CacheBook
|
||||||
|
import io.legado.app.model.cache.CacheDownloadRequest
|
||||||
|
import io.legado.app.model.cache.ChapterSelection
|
||||||
import splitties.init.appCtx
|
import splitties.init.appCtx
|
||||||
|
|
||||||
class CacheBookDownloadRepository(
|
class CacheBookDownloadRepository(
|
||||||
private val bookDao: BookDao
|
private val bookDao: BookDao
|
||||||
) : BookCacheDownloadGateway {
|
) : BookCacheDownloadGateway {
|
||||||
|
|
||||||
|
override suspend fun start(request: CacheDownloadRequest) {
|
||||||
|
val book = bookDao.getBook(request.bookUrl) ?: return
|
||||||
|
CacheBook.start(appCtx, request, isLocal = book.isLocal)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun start(bookUrl: String, chapterIndices: List<Int>) {
|
override suspend fun start(bookUrl: String, chapterIndices: List<Int>) {
|
||||||
val book = bookDao.getBook(bookUrl) ?: return
|
start(
|
||||||
CacheBook.start(appCtx, book, chapterIndices)
|
CacheDownloadRequest(
|
||||||
|
bookUrl = bookUrl,
|
||||||
|
selection = ChapterSelection.Indices(chapterIndices.toSet()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun start(bookUrl: String, startIndex: Int, endIndex: Int) {
|
||||||
|
start(
|
||||||
|
CacheDownloadRequest(
|
||||||
|
bookUrl = bookUrl,
|
||||||
|
selection = ChapterSelection.Range(startIndex, endIndex),
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import io.legado.app.data.repository.WebDavReadingProgressRepository
|
|||||||
import io.legado.app.domain.gateway.BookCacheCleanupGateway
|
import io.legado.app.domain.gateway.BookCacheCleanupGateway
|
||||||
import io.legado.app.domain.gateway.AppStartupGateway
|
import io.legado.app.domain.gateway.AppStartupGateway
|
||||||
import io.legado.app.domain.gateway.BookCacheDownloadGateway
|
import io.legado.app.domain.gateway.BookCacheDownloadGateway
|
||||||
|
import io.legado.app.domain.gateway.BookSearchGateway
|
||||||
import io.legado.app.domain.gateway.BookSourceCallbackGateway
|
import io.legado.app.domain.gateway.BookSourceCallbackGateway
|
||||||
import io.legado.app.domain.gateway.DatabaseMaintenanceGateway
|
import io.legado.app.domain.gateway.DatabaseMaintenanceGateway
|
||||||
import io.legado.app.domain.gateway.LocalBookGateway
|
import io.legado.app.domain.gateway.LocalBookGateway
|
||||||
@@ -46,6 +47,7 @@ import io.legado.app.domain.usecase.GetReadingProgressUseCase
|
|||||||
import io.legado.app.domain.usecase.RemoveBookGroupAssignmentUseCase
|
import io.legado.app.domain.usecase.RemoveBookGroupAssignmentUseCase
|
||||||
import io.legado.app.ui.widget.components.explore.ExploreKindUiUseCase
|
import io.legado.app.ui.widget.components.explore.ExploreKindUiUseCase
|
||||||
import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
|
import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
|
||||||
|
import io.legado.app.domain.usecase.SearchBooksUseCase
|
||||||
import io.legado.app.domain.usecase.ShrinkDatabaseUseCase
|
import io.legado.app.domain.usecase.ShrinkDatabaseUseCase
|
||||||
import io.legado.app.domain.usecase.UpdateBooksGroupUseCase
|
import io.legado.app.domain.usecase.UpdateBooksGroupUseCase
|
||||||
import io.legado.app.domain.usecase.UploadReadingProgressUseCase
|
import io.legado.app.domain.usecase.UploadReadingProgressUseCase
|
||||||
@@ -140,7 +142,12 @@ val appModule = module {
|
|||||||
single<ReadingProgressGateway> { WebDavReadingProgressRepository() }
|
single<ReadingProgressGateway> { WebDavReadingProgressRepository() }
|
||||||
single<BookDomainRepository> { BookDomainRepositoryImpl(get(), get()) }
|
single<BookDomainRepository> { BookDomainRepositoryImpl(get(), get()) }
|
||||||
single<ExploreRepository> { ExploreRepositoryImpl(get()) }
|
single<ExploreRepository> { ExploreRepositoryImpl(get()) }
|
||||||
single<SearchRepository> { SearchRepositoryImpl(get()) }
|
single {
|
||||||
|
SearchRepositoryImpl(get())
|
||||||
|
}
|
||||||
|
single<SearchRepository> { get<SearchRepositoryImpl>() }
|
||||||
|
single<BookSearchGateway> { get<SearchRepositoryImpl>() }
|
||||||
|
singleOf(::SearchBooksUseCase)
|
||||||
|
|
||||||
single<ImageLoader> {
|
single<ImageLoader> {
|
||||||
ImageLoader.Builder(get())
|
ImageLoader.Builder(get())
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
package io.legado.app.domain.gateway
|
package io.legado.app.domain.gateway
|
||||||
|
|
||||||
|
import io.legado.app.model.cache.CacheDownloadRequest
|
||||||
|
|
||||||
interface BookCacheDownloadGateway {
|
interface BookCacheDownloadGateway {
|
||||||
|
suspend fun start(request: 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package io.legado.app.domain.usecase
|
|||||||
import io.legado.app.domain.gateway.BookCacheDownloadGateway
|
import io.legado.app.domain.gateway.BookCacheDownloadGateway
|
||||||
import io.legado.app.domain.model.CacheableBook
|
import io.legado.app.domain.model.CacheableBook
|
||||||
import io.legado.app.domain.repository.BookDomainRepository
|
import io.legado.app.domain.repository.BookDomainRepository
|
||||||
|
import io.legado.app.model.cache.CacheDownloadRequest
|
||||||
|
import io.legado.app.model.cache.CacheDownloadSource
|
||||||
|
import io.legado.app.model.cache.ChapterSelection
|
||||||
|
|
||||||
class BatchCacheDownloadUseCase(
|
class BatchCacheDownloadUseCase(
|
||||||
private val bookRepository: BookDomainRepository,
|
private val bookRepository: BookDomainRepository,
|
||||||
@@ -31,13 +34,16 @@ class BatchCacheDownloadUseCase(
|
|||||||
): Boolean {
|
): Boolean {
|
||||||
if (book.isLocal) return false
|
if (book.isLocal) return false
|
||||||
if (skipAudioBooks && book.isAudio) return false
|
if (skipAudioBooks && book.isAudio) return false
|
||||||
val indices = if (downloadAllChapters) {
|
val startIndex = if (downloadAllChapters) 0 else book.durChapterIndex
|
||||||
(0..book.lastChapterIndex).toList()
|
val endIndex = book.lastChapterIndex
|
||||||
} else {
|
if (endIndex < startIndex) return false
|
||||||
(book.durChapterIndex..book.lastChapterIndex).toList()
|
bookCacheDownloadGateway.start(
|
||||||
}
|
CacheDownloadRequest(
|
||||||
if (indices.isEmpty()) return false
|
bookUrl = book.bookUrl,
|
||||||
bookCacheDownloadGateway.start(book.bookUrl, indices)
|
selection = ChapterSelection.Range(startIndex, endIndex),
|
||||||
|
source = CacheDownloadSource.Batch,
|
||||||
|
)
|
||||||
|
)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package io.legado.app.domain.usecase
|
package io.legado.app.domain.usecase
|
||||||
|
|
||||||
import io.legado.app.domain.gateway.BookCacheDownloadGateway
|
import io.legado.app.domain.gateway.BookCacheDownloadGateway
|
||||||
|
import io.legado.app.model.cache.CacheDownloadRequest
|
||||||
|
import io.legado.app.model.cache.CacheDownloadSource
|
||||||
|
import io.legado.app.model.cache.ChapterSelection
|
||||||
|
|
||||||
class CacheBookChaptersUseCase(
|
class CacheBookChaptersUseCase(
|
||||||
private val bookCacheDownloadGateway: BookCacheDownloadGateway
|
private val bookCacheDownloadGateway: BookCacheDownloadGateway
|
||||||
@@ -9,7 +12,25 @@ class CacheBookChaptersUseCase(
|
|||||||
suspend fun execute(bookUrl: String, chapterIndices: Iterable<Int>): Int {
|
suspend fun execute(bookUrl: String, chapterIndices: Iterable<Int>): Int {
|
||||||
val indices = chapterIndices.distinct()
|
val indices = chapterIndices.distinct()
|
||||||
if (indices.isEmpty()) return 0
|
if (indices.isEmpty()) return 0
|
||||||
bookCacheDownloadGateway.start(bookUrl, indices)
|
bookCacheDownloadGateway.start(
|
||||||
|
CacheDownloadRequest(
|
||||||
|
bookUrl = bookUrl,
|
||||||
|
selection = ChapterSelection.Indices(indices.toSet()),
|
||||||
|
source = CacheDownloadSource.Manual,
|
||||||
|
)
|
||||||
|
)
|
||||||
return indices.size
|
return indices.size
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun executeRange(bookUrl: String, startIndex: Int, endIndex: Int): Int {
|
||||||
|
if (endIndex < startIndex) return 0
|
||||||
|
bookCacheDownloadGateway.start(
|
||||||
|
CacheDownloadRequest(
|
||||||
|
bookUrl = bookUrl,
|
||||||
|
selection = ChapterSelection.Range(startIndex, endIndex),
|
||||||
|
source = CacheDownloadSource.Manual,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return endIndex - startIndex + 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,22 +2,26 @@ package io.legado.app.model
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import io.legado.app.constant.AppLog
|
import io.legado.app.constant.AppLog
|
||||||
import io.legado.app.constant.EventBus
|
|
||||||
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.exception.ConcurrentException
|
||||||
import io.legado.app.help.book.BookHelp
|
|
||||||
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.CompositeCoroutine
|
||||||
import io.legado.app.help.coroutine.Coroutine
|
import io.legado.app.help.coroutine.Coroutine
|
||||||
import io.legado.app.model.webBook.WebBook
|
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 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.postEvent
|
|
||||||
import io.legado.app.utils.startService
|
import io.legado.app.utils.startService
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -75,20 +79,20 @@ object CacheBook {
|
|||||||
if (!emitted) delay(800)
|
if (!emitted) delay(800)
|
||||||
}
|
}
|
||||||
}.onStart {
|
}.onStart {
|
||||||
postEvent(EventBus.UP_DOWNLOAD_STATE, "")
|
|
||||||
updateSummary()
|
updateSummary()
|
||||||
}.onEachParallel(OtherConfig.cacheBookThreadCount.coerceAtLeast(1)) {
|
}.onEachParallel(OtherConfig.cacheBookThreadCount.coerceAtLeast(1)) {
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
it.download(this, context)
|
it.download(this, context)
|
||||||
}
|
}
|
||||||
}.onCompletion {
|
}.onCompletion {
|
||||||
postEvent(EventBus.UP_DOWNLOAD_STATE, "")
|
|
||||||
updateSummary()
|
updateSummary()
|
||||||
}.collect()
|
}.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val coordinator = CacheBookCoordinator()
|
private val coordinator = CacheBookCoordinator()
|
||||||
|
private val stateStore = CacheDownloadStateStore()
|
||||||
|
val downloadStateFlow = stateStore.stateFlow
|
||||||
|
|
||||||
private val _cacheSuccessFlow = MutableSharedFlow<BookChapter>(extraBufferCapacity = 64)
|
private val _cacheSuccessFlow = MutableSharedFlow<BookChapter>(extraBufferCapacity = 64)
|
||||||
val cacheSuccessFlow = _cacheSuccessFlow.asSharedFlow()
|
val cacheSuccessFlow = _cacheSuccessFlow.asSharedFlow()
|
||||||
@@ -117,7 +121,7 @@ object CacheBook {
|
|||||||
get() = coordinator.taskMap
|
get() = coordinator.taskMap
|
||||||
|
|
||||||
fun errorIndices(bookUrl: String): Set<Int> {
|
fun errorIndices(bookUrl: String): Set<Int> {
|
||||||
return errorIndexMap[bookUrl]?.toSet().orEmpty()
|
return stateStore.bookState(bookUrl)?.failedIndices.orEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun collectQueueStats(): QueueStats {
|
private fun collectQueueStats(): QueueStats {
|
||||||
@@ -168,11 +172,54 @@ object CacheBook {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun start(context: Context, book: Book, selectedIndices: List<Int>) {
|
fun start(context: Context, book: Book, selectedIndices: List<Int>) {
|
||||||
if (!book.isLocal && selectedIndices.isNotEmpty()) {
|
start(
|
||||||
|
context = context,
|
||||||
|
request = CacheDownloadRequest(
|
||||||
|
bookUrl = book.bookUrl,
|
||||||
|
selection = ChapterSelection.Indices(selectedIndices.toSet()),
|
||||||
|
),
|
||||||
|
isLocal = book.isLocal,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun start(context: Context, book: Book, startIndex: Int, endIndex: Int) {
|
||||||
|
start(
|
||||||
|
context = context,
|
||||||
|
request = CacheDownloadRequest(
|
||||||
|
bookUrl = book.bookUrl,
|
||||||
|
selection = ChapterSelection.Range(startIndex, endIndex),
|
||||||
|
),
|
||||||
|
isLocal = book.isLocal,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun start(context: Context, request: CacheDownloadRequest, isLocal: Boolean = false) {
|
||||||
|
if (isLocal) return
|
||||||
|
when (val selection = request.selection) {
|
||||||
|
is ChapterSelection.Range -> {
|
||||||
|
if (selection.end < selection.start) return
|
||||||
|
}
|
||||||
|
is ChapterSelection.Indices -> {
|
||||||
|
if (selection.values.isEmpty()) return
|
||||||
|
}
|
||||||
|
is ChapterSelection.Single -> Unit
|
||||||
|
}
|
||||||
context.startService<CacheBookService> {
|
context.startService<CacheBookService> {
|
||||||
action = IntentAction.start
|
action = IntentAction.start
|
||||||
putExtra("bookUrl", book.bookUrl)
|
putExtra("bookUrl", request.bookUrl)
|
||||||
putIntegerArrayListExtra("indices", ArrayList(selectedIndices))
|
putExtra("source", request.source.name)
|
||||||
|
when (val selection = request.selection) {
|
||||||
|
is ChapterSelection.Range -> {
|
||||||
|
putExtra("start", selection.start)
|
||||||
|
putExtra("end", selection.end)
|
||||||
|
}
|
||||||
|
is ChapterSelection.Indices -> {
|
||||||
|
putIntegerArrayListExtra("indices", ArrayList(selection.values))
|
||||||
|
}
|
||||||
|
is ChapterSelection.Single -> {
|
||||||
|
putExtra("start", selection.index)
|
||||||
|
putExtra("end", selection.index)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,9 +234,9 @@ object CacheBook {
|
|||||||
fun removeBook(bookUrl: String): Boolean {
|
fun removeBook(bookUrl: String): Boolean {
|
||||||
val model = cacheBookMap.remove(bookUrl) ?: return false
|
val model = cacheBookMap.remove(bookUrl) ?: return false
|
||||||
model.stop()
|
model.stop()
|
||||||
|
stateStore.removeBook(bookUrl)
|
||||||
updateSummary()
|
updateSummary()
|
||||||
_queueChangedFlow.tryEmit(bookUrl)
|
_queueChangedFlow.tryEmit(bookUrl)
|
||||||
postEvent(EventBus.UP_DOWNLOAD, bookUrl)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +258,7 @@ object CacheBook {
|
|||||||
successDownloadSet.clear()
|
successDownloadSet.clear()
|
||||||
errorDownloadMap.clear()
|
errorDownloadMap.clear()
|
||||||
errorIndexMap.clear()
|
errorIndexMap.clear()
|
||||||
|
stateStore.clear()
|
||||||
updateSummary()
|
updateSummary()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,15 +289,26 @@ object CacheBook {
|
|||||||
get() = lastQueueStats.waitingCount > 0 || lastQueueStats.downloadingCount > 0
|
get() = lastQueueStats.waitingCount > 0 || lastQueueStats.downloadingCount > 0
|
||||||
|
|
||||||
private fun onTaskQueuesChanged(bookUrl: String) {
|
private fun onTaskQueuesChanged(bookUrl: String) {
|
||||||
|
cacheBookMap[bookUrl]?.let { model ->
|
||||||
|
stateStore.updateBookQueue(
|
||||||
|
bookUrl = bookUrl,
|
||||||
|
waitingCount = model.queueCounts().first,
|
||||||
|
runningIndices = model.downloadingIndices(),
|
||||||
|
)
|
||||||
|
_downloadingIndicesFlow.tryEmit(bookUrl to model.downloadingIndices())
|
||||||
|
_downloadErrorFlow.tryEmit(bookUrl to errorIndices(bookUrl))
|
||||||
|
}
|
||||||
updateSummary()
|
updateSummary()
|
||||||
_queueChangedFlow.tryEmit(bookUrl)
|
_queueChangedFlow.tryEmit(bookUrl)
|
||||||
postEvent(EventBus.UP_DOWNLOAD, bookUrl)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onTaskRemoved(bookUrl: String) {
|
private fun onTaskRemoved(bookUrl: String, clearState: Boolean = false) {
|
||||||
cacheBookMap.remove(bookUrl)
|
cacheBookMap.remove(bookUrl)
|
||||||
|
if (clearState) {
|
||||||
|
stateStore.removeBook(bookUrl)
|
||||||
|
}
|
||||||
updateSummary()
|
updateSummary()
|
||||||
postEvent(EventBus.UP_DOWNLOAD, bookUrl)
|
_queueChangedFlow.tryEmit(bookUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
class CacheBookModel(
|
class CacheBookModel(
|
||||||
@@ -257,40 +316,50 @@ object CacheBook {
|
|||||||
@Volatile var book: Book
|
@Volatile var book: Book
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val waitDownloadSet = linkedSetOf<Int>()
|
private val queue = CacheDownloadQueue()
|
||||||
private val onDownloadSet = linkedSetOf<Int>()
|
private val onDownloadSet = linkedSetOf<Int>()
|
||||||
private val pausedDownloadSet = hashSetOf<Int>()
|
private val pausedDownloadSet = hashSetOf<Int>()
|
||||||
private val chapterTasks = hashMapOf<Int, Coroutine<*>>()
|
private val chapterTasks = hashMapOf<Int, Coroutine<*>>()
|
||||||
private val tasks = CompositeCoroutine()
|
private val tasks = CompositeCoroutine()
|
||||||
|
private val repository = CacheDownloadRepository()
|
||||||
private var isStopped = false
|
private var isStopped = false
|
||||||
private var waitingRetry = false
|
private var waitingRetry = false
|
||||||
private var isLoading = false
|
private var isLoading = false
|
||||||
|
|
||||||
init {
|
|
||||||
postEvent(EventBus.UP_DOWNLOAD, book.bookUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun notifyDownloadSetChanged() {
|
private fun notifyDownloadSetChanged() {
|
||||||
_downloadingIndicesFlow.tryEmit(book.bookUrl to onDownloadSet.toSet())
|
_downloadingIndicesFlow.tryEmit(book.bookUrl to onDownloadSet.toSet())
|
||||||
|
if (cacheBookMap[book.bookUrl] === this) {
|
||||||
|
stateStore.updateBookQueue(
|
||||||
|
bookUrl = book.bookUrl,
|
||||||
|
waitingCount = queue.waitingCount(),
|
||||||
|
runningIndices = onDownloadSet.toSet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun notifyErrorChanged() {
|
private fun notifyErrorChanged() {
|
||||||
val errors = errorIndexMap[book.bookUrl]?.toSet() ?: emptySet()
|
val errors = errorIndices(book.bookUrl)
|
||||||
_downloadErrorFlow.tryEmit(book.bookUrl to errors)
|
_downloadErrorFlow.tryEmit(book.bookUrl to errors)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun queueCounts(): Pair<Int, Int> = waitDownloadSet.size to onDownloadSet.size
|
fun queueCounts(): Pair<Int, Int> = queue.waitingCount() to onDownloadSet.size
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun waitingIndices(): Set<Int> = waitDownloadSet.toSet()
|
fun isWaiting(index: Int): Boolean = queue.isWaiting(index)
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun isDownloading(index: Int): Boolean = onDownloadSet.contains(index)
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun waitingIndices(): Set<Int> = queue.waitingIndices()
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun downloadingIndices(): Set<Int> = onDownloadSet.toSet()
|
fun downloadingIndices(): Set<Int> = onDownloadSet.toSet()
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun isRun(): Boolean {
|
fun isRun(): Boolean {
|
||||||
return waitDownloadSet.isNotEmpty() || onDownloadSet.isNotEmpty() || isLoading
|
return queue.waitingCount() > 0 || onDownloadSet.isNotEmpty() || isLoading
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
@@ -309,7 +378,7 @@ object CacheBook {
|
|||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun stop() {
|
fun stop() {
|
||||||
waitDownloadSet.clear()
|
queue.clear()
|
||||||
pausedDownloadSet.clear()
|
pausedDownloadSet.clear()
|
||||||
chapterTasks.clear()
|
chapterTasks.clear()
|
||||||
tasks.clear()
|
tasks.clear()
|
||||||
@@ -322,18 +391,39 @@ object CacheBook {
|
|||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun addDownload(start: Int, end: Int) {
|
fun addDownload(start: Int, end: Int) {
|
||||||
addDownloads(start..end)
|
addRequest(
|
||||||
|
CacheDownloadRequest(
|
||||||
|
book.bookUrl,
|
||||||
|
ChapterSelection.Range(start, end),
|
||||||
|
CacheDownloadSource.ReadPreload,
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun addDownloads(indices: Iterable<Int>) {
|
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
|
isStopped = false
|
||||||
for (i in indices) {
|
when (val selection = request.selection) {
|
||||||
pausedDownloadSet.remove(i)
|
is ChapterSelection.Range -> {
|
||||||
if (!onDownloadSet.contains(i)) {
|
pausedDownloadSet.removeAll { it in selection.start..selection.end }
|
||||||
waitDownloadSet.add(i)
|
|
||||||
}
|
}
|
||||||
|
is ChapterSelection.Indices -> selection.values.forEach { pausedDownloadSet.remove(it) }
|
||||||
|
is ChapterSelection.Single -> pausedDownloadSet.remove(selection.index)
|
||||||
}
|
}
|
||||||
|
queue.enqueue(request)
|
||||||
cacheBookMap[book.bookUrl] = this
|
cacheBookMap[book.bookUrl] = this
|
||||||
isLoading = false
|
isLoading = false
|
||||||
notifyDownloadSetChanged()
|
notifyDownloadSetChanged()
|
||||||
@@ -351,6 +441,7 @@ object CacheBook {
|
|||||||
successDownloadSet.add(chapter.primaryStr())
|
successDownloadSet.add(chapter.primaryStr())
|
||||||
errorDownloadMap.remove(chapter.primaryStr())
|
errorDownloadMap.remove(chapter.primaryStr())
|
||||||
errorIndexMap[book.bookUrl]?.remove(chapter.index)
|
errorIndexMap[book.bookUrl]?.remove(chapter.index)
|
||||||
|
stateStore.markSuccess(book.bookUrl, chapter.index)
|
||||||
notifyDownloadSetChanged()
|
notifyDownloadSetChanged()
|
||||||
notifyErrorChanged()
|
notifyErrorChanged()
|
||||||
_cacheSuccessFlow.tryEmit(chapter)
|
_cacheSuccessFlow.tryEmit(chapter)
|
||||||
@@ -363,6 +454,7 @@ object CacheBook {
|
|||||||
errorDownloadMap.merge(chapter.primaryStr(), 1) { old, inc -> old + inc }
|
errorDownloadMap.merge(chapter.primaryStr(), 1) { old, inc -> old + inc }
|
||||||
errorIndexMap.getOrPut(book.bookUrl) { ConcurrentHashMap.newKeySet() }
|
errorIndexMap.getOrPut(book.bookUrl) { ConcurrentHashMap.newKeySet() }
|
||||||
.add(chapter.index)
|
.add(chapter.index)
|
||||||
|
stateStore.markFailed(book.bookUrl, chapter.index)
|
||||||
}
|
}
|
||||||
onDownloadSet.remove(chapter.index)
|
onDownloadSet.remove(chapter.index)
|
||||||
chapterTasks.remove(chapter.index)
|
chapterTasks.remove(chapter.index)
|
||||||
@@ -372,7 +464,7 @@ object CacheBook {
|
|||||||
private fun onPostError(chapter: BookChapter, error: Throwable) {
|
private fun onPostError(chapter: BookChapter, error: Throwable) {
|
||||||
val retryCount = errorDownloadMap[chapter.primaryStr()] ?: 0
|
val retryCount = errorDownloadMap[chapter.primaryStr()] ?: 0
|
||||||
if (retryCount < 3 && !isStopped) {
|
if (retryCount < 3 && !isStopped) {
|
||||||
waitDownloadSet.add(chapter.index)
|
queue.enqueue(ChapterSelection.Single(chapter.index))
|
||||||
} else {
|
} else {
|
||||||
AppLog.put("下载${book.name}-${chapter.title}失败\n${error.localizedMessage}", error)
|
AppLog.put("下载${book.name}-${chapter.title}失败\n${error.localizedMessage}", error)
|
||||||
}
|
}
|
||||||
@@ -391,14 +483,16 @@ object CacheBook {
|
|||||||
private fun onCancel(index: Int) {
|
private fun onCancel(index: Int) {
|
||||||
onDownloadSet.remove(index)
|
onDownloadSet.remove(index)
|
||||||
chapterTasks.remove(index)
|
chapterTasks.remove(index)
|
||||||
if (!isStopped && !pausedDownloadSet.remove(index)) waitDownloadSet.add(index)
|
if (!isStopped && !pausedDownloadSet.remove(index)) {
|
||||||
|
queue.enqueue(ChapterSelection.Single(index))
|
||||||
|
}
|
||||||
notifyDownloadSetChanged()
|
notifyDownloadSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
private fun onFinally() {
|
private fun onFinally() {
|
||||||
val bookUrl = book.bookUrl
|
val bookUrl = book.bookUrl
|
||||||
if (waitDownloadSet.isEmpty() && onDownloadSet.isEmpty()) {
|
if (queue.waitingCount() == 0 && onDownloadSet.isEmpty()) {
|
||||||
CacheBook.onTaskRemoved(bookUrl)
|
CacheBook.onTaskRemoved(bookUrl)
|
||||||
} else {
|
} else {
|
||||||
CacheBook.onTaskQueuesChanged(bookUrl)
|
CacheBook.onTaskQueuesChanged(bookUrl)
|
||||||
@@ -408,7 +502,7 @@ object CacheBook {
|
|||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun removeDownload(index: Int): Boolean {
|
fun removeDownload(index: Int): Boolean {
|
||||||
val removedWaiting = waitDownloadSet.remove(index)
|
val removedWaiting = queue.removeChapter(index)
|
||||||
val task = chapterTasks.remove(index)
|
val task = chapterTasks.remove(index)
|
||||||
val removedRunning = onDownloadSet.contains(index) || task != null
|
val removedRunning = onDownloadSet.contains(index) || task != null
|
||||||
if (removedRunning) {
|
if (removedRunning) {
|
||||||
@@ -420,8 +514,8 @@ object CacheBook {
|
|||||||
}
|
}
|
||||||
if (!removedWaiting && !removedRunning) return false
|
if (!removedWaiting && !removedRunning) return false
|
||||||
notifyDownloadSetChanged()
|
notifyDownloadSetChanged()
|
||||||
if (waitDownloadSet.isEmpty() && onDownloadSet.isEmpty()) {
|
if (queue.waitingCount() == 0 && onDownloadSet.isEmpty()) {
|
||||||
CacheBook.onTaskRemoved(book.bookUrl)
|
CacheBook.onTaskRemoved(book.bookUrl, clearState = true)
|
||||||
} else {
|
} else {
|
||||||
CacheBook.onTaskQueuesChanged(book.bookUrl)
|
CacheBook.onTaskQueuesChanged(book.bookUrl)
|
||||||
}
|
}
|
||||||
@@ -433,42 +527,39 @@ object CacheBook {
|
|||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun download(scope: CoroutineScope, context: CoroutineContext) {
|
fun download(scope: CoroutineScope, context: CoroutineContext) {
|
||||||
val chapterIndex = waitDownloadSet.firstOrNull()
|
val candidate = queue.next(book.bookUrl, onDownloadSet)
|
||||||
if (chapterIndex == null) {
|
if (candidate == null) {
|
||||||
if (!isLoading && onDownloadSet.isEmpty()) {
|
if (!isLoading && onDownloadSet.isEmpty()) {
|
||||||
CacheBook.onTaskRemoved(book.bookUrl)
|
CacheBook.onTaskRemoved(book.bookUrl)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
val chapterIndex = candidate.chapterIndex
|
||||||
if (onDownloadSet.contains(chapterIndex)) {
|
if (onDownloadSet.contains(chapterIndex)) {
|
||||||
waitDownloadSet.remove(chapterIndex)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, chapterIndex) ?: run {
|
val chapter = repository.getChapter(book.bookUrl, chapterIndex) ?: run {
|
||||||
waitDownloadSet.remove(chapterIndex)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (chapter.isVolume) {
|
if (chapter.isVolume) {
|
||||||
postEvent(EventBus.SAVE_CONTENT, Pair(book, chapter))
|
|
||||||
_cacheSuccessFlow.tryEmit(chapter)
|
_cacheSuccessFlow.tryEmit(chapter)
|
||||||
waitDownloadSet.remove(chapterIndex)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (BookHelp.hasImageContent(book, chapter)) {
|
if (repository.hasImageContent(book, chapter)) {
|
||||||
waitDownloadSet.remove(chapterIndex)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
waitDownloadSet.remove(chapterIndex)
|
|
||||||
onDownloadSet.add(chapterIndex)
|
onDownloadSet.add(chapterIndex)
|
||||||
notifyDownloadSetChanged()
|
notifyDownloadSetChanged()
|
||||||
|
|
||||||
if (BookHelp.hasContent(book, chapter)) {
|
if (repository.hasContent(book, chapter)) {
|
||||||
val task = Coroutine.async(scope, context, executeContext = context) {
|
val task = repository.saveCachedImagesTask(
|
||||||
BookHelp.getContent(book, chapter)?.let {
|
scope = scope,
|
||||||
BookHelp.saveImages(bookSource, book, chapter, it, 1)
|
context = context,
|
||||||
}
|
bookSource = bookSource,
|
||||||
}.onSuccess {
|
book = book,
|
||||||
|
chapter = chapter,
|
||||||
|
).onSuccess {
|
||||||
onSuccess(chapter)
|
onSuccess(chapter)
|
||||||
}.onError {
|
}.onError {
|
||||||
onPreError(chapter, it)
|
onPreError(chapter, it)
|
||||||
@@ -485,14 +576,14 @@ object CacheBook {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val task = WebBook.getContent(
|
val task = repository.downloadContentTask(
|
||||||
scope,
|
scope = scope,
|
||||||
bookSource,
|
bookSource = bookSource,
|
||||||
book,
|
book = book,
|
||||||
chapter,
|
chapter = chapter,
|
||||||
context = context,
|
context = context,
|
||||||
start = CoroutineStart.LAZY,
|
start = CoroutineStart.LAZY,
|
||||||
executeContext = context
|
executeContext = context,
|
||||||
).onSuccess { content ->
|
).onSuccess { content ->
|
||||||
onSuccess(chapter)
|
onSuccess(chapter)
|
||||||
downloadFinish(chapter, content)
|
downloadFinish(chapter, content)
|
||||||
@@ -515,11 +606,11 @@ object CacheBook {
|
|||||||
suspend fun downloadAwait(chapter: BookChapter): String {
|
suspend fun downloadAwait(chapter: BookChapter): String {
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
onDownloadSet.add(chapter.index)
|
onDownloadSet.add(chapter.index)
|
||||||
waitDownloadSet.remove(chapter.index)
|
queue.removeChapter(chapter.index)
|
||||||
notifyDownloadSetChanged()
|
notifyDownloadSetChanged()
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
val content = WebBook.getContentAwait(bookSource, book, chapter)
|
val content = repository.downloadContentAwait(bookSource, book, chapter)
|
||||||
onSuccess(chapter)
|
onSuccess(chapter)
|
||||||
ReadBook.downloadedChapters.add(chapter.index)
|
ReadBook.downloadedChapters.add(chapter.index)
|
||||||
ReadBook.downloadFailChapters.remove(chapter.index)
|
ReadBook.downloadFailChapters.remove(chapter.index)
|
||||||
@@ -544,15 +635,16 @@ object CacheBook {
|
|||||||
) {
|
) {
|
||||||
if (onDownloadSet.contains(chapter.index)) return
|
if (onDownloadSet.contains(chapter.index)) return
|
||||||
onDownloadSet.add(chapter.index)
|
onDownloadSet.add(chapter.index)
|
||||||
waitDownloadSet.remove(chapter.index)
|
queue.removeChapter(chapter.index)
|
||||||
notifyDownloadSetChanged()
|
notifyDownloadSetChanged()
|
||||||
|
|
||||||
WebBook.getContent(
|
repository.downloadContentTask(
|
||||||
scope,
|
scope = scope,
|
||||||
bookSource,
|
bookSource = bookSource,
|
||||||
book,
|
book = book,
|
||||||
chapter,
|
chapter = chapter,
|
||||||
start = CoroutineStart.LAZY,
|
start = CoroutineStart.LAZY,
|
||||||
|
context = IO,
|
||||||
executeContext = IO,
|
executeContext = IO,
|
||||||
semaphore = semaphore
|
semaphore = semaphore
|
||||||
).onSuccess { content ->
|
).onSuccess { content ->
|
||||||
@@ -579,15 +671,15 @@ object CacheBook {
|
|||||||
resetPageOffset: Boolean = false,
|
resetPageOffset: Boolean = false,
|
||||||
canceled: Boolean = false
|
canceled: Boolean = false
|
||||||
) {
|
) {
|
||||||
if (ReadBook.book?.bookUrl == book.bookUrl) {
|
ReadingCacheEvents.emit(
|
||||||
ReadBook.contentLoadFinish(
|
ReadingCacheEvent.ContentReady(
|
||||||
book = book,
|
book = book,
|
||||||
chapter = chapter,
|
chapter = chapter,
|
||||||
content = content,
|
content = content,
|
||||||
resetPageOffset = resetPageOffset,
|
resetPageOffset = resetPageOffset,
|
||||||
canceled = canceled
|
canceled = canceled,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import io.legado.app.help.config.ReadBookConfig
|
|||||||
import io.legado.app.help.coroutine.Coroutine
|
import io.legado.app.help.coroutine.Coroutine
|
||||||
import io.legado.app.help.globalExecutor
|
import io.legado.app.help.globalExecutor
|
||||||
import io.legado.app.model.localBook.TextFile
|
import io.legado.app.model.localBook.TextFile
|
||||||
|
import io.legado.app.model.cache.ReadingCacheEvent
|
||||||
|
import io.legado.app.model.cache.ReadingCacheEvents
|
||||||
import io.legado.app.model.webBook.WebBook
|
import io.legado.app.model.webBook.WebBook
|
||||||
import io.legado.app.service.BaseReadAloudService
|
import io.legado.app.service.BaseReadAloudService
|
||||||
import io.legado.app.service.CacheBookService
|
import io.legado.app.service.CacheBookService
|
||||||
@@ -88,6 +90,26 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
|
|||||||
private val nextChapterLoadingLock = Mutex()
|
private val nextChapterLoadingLock = Mutex()
|
||||||
var readStartTime: Long = System.currentTimeMillis()
|
var readStartTime: Long = System.currentTimeMillis()
|
||||||
|
|
||||||
|
init {
|
||||||
|
launch {
|
||||||
|
ReadingCacheEvents.events.collect { event ->
|
||||||
|
when (event) {
|
||||||
|
is ReadingCacheEvent.ContentReady -> {
|
||||||
|
if (book?.bookUrl == event.book.bookUrl) {
|
||||||
|
contentLoadFinish(
|
||||||
|
book = event.book,
|
||||||
|
chapter = event.chapter,
|
||||||
|
content = event.content,
|
||||||
|
resetPageOffset = event.resetPageOffset,
|
||||||
|
canceled = event.canceled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* 跳转进度前进度记录 */
|
/* 跳转进度前进度记录 */
|
||||||
var lastBookProgress: BookProgress? = null
|
var lastBookProgress: BookProgress? = null
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package io.legado.app.model.cache
|
||||||
|
|
||||||
|
data class CacheDownloadRequest(
|
||||||
|
val bookUrl: String,
|
||||||
|
val selection: ChapterSelection,
|
||||||
|
val source: CacheDownloadSource = CacheDownloadSource.Manual,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface ChapterSelection {
|
||||||
|
data class Range(val start: Int, val end: Int) : ChapterSelection
|
||||||
|
data class Indices(val values: Set<Int>) : ChapterSelection
|
||||||
|
data class Single(val index: Int) : ChapterSelection
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class CacheDownloadSource {
|
||||||
|
Manual,
|
||||||
|
Batch,
|
||||||
|
ReadPreload,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CacheDownloadCandidate(
|
||||||
|
val bookUrl: String,
|
||||||
|
val chapterIndex: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CacheDownloadQueueSnapshot(
|
||||||
|
val waitingCount: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CacheDownloadState(
|
||||||
|
val isRunning: Boolean = false,
|
||||||
|
val totalWaiting: Int = 0,
|
||||||
|
val totalRunning: Int = 0,
|
||||||
|
val totalSuccess: Int = 0,
|
||||||
|
val totalFailure: Int = 0,
|
||||||
|
val books: Map<String, CacheBookDownloadState> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CacheBookDownloadState(
|
||||||
|
val bookUrl: String,
|
||||||
|
val waitingCount: Int = 0,
|
||||||
|
val runningIndices: Set<Int> = emptySet(),
|
||||||
|
val failedIndices: Set<Int> = emptySet(),
|
||||||
|
val successIndices: Set<Int> = emptySet(),
|
||||||
|
val successCount: Int = 0,
|
||||||
|
)
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package io.legado.app.model.cache
|
||||||
|
|
||||||
|
class CacheDownloadQueue {
|
||||||
|
|
||||||
|
private data class RangeCursor(
|
||||||
|
val start: Int,
|
||||||
|
val end: Int,
|
||||||
|
var next: Int = start,
|
||||||
|
) {
|
||||||
|
fun contains(index: Int): Boolean = index in next..end
|
||||||
|
fun remainingCount(
|
||||||
|
emittedIndices: Set<Int>,
|
||||||
|
removedIndices: Set<Int>,
|
||||||
|
): Int {
|
||||||
|
if (next > end) return 0
|
||||||
|
val rawCount = end - next + 1
|
||||||
|
val emittedCount = emittedIndices.count { it in next..end }
|
||||||
|
val removedCount = removedIndices.count { it in next..end && it !in emittedIndices }
|
||||||
|
val excludedCount = emittedCount + removedCount
|
||||||
|
return rawCount - excludedCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val ranges = ArrayDeque<RangeCursor>()
|
||||||
|
private val indices = linkedSetOf<Int>()
|
||||||
|
private val emittedIndices = hashSetOf<Int>()
|
||||||
|
private val removedIndices = hashSetOf<Int>()
|
||||||
|
|
||||||
|
fun enqueue(request: CacheDownloadRequest) {
|
||||||
|
enqueue(request.selection)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun enqueue(selection: ChapterSelection) {
|
||||||
|
when (selection) {
|
||||||
|
is ChapterSelection.Range -> addRange(selection.start, selection.end)
|
||||||
|
is ChapterSelection.Indices -> addIndices(selection.values)
|
||||||
|
is ChapterSelection.Single -> addIndex(selection.index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun next(bookUrl: String, runningIndices: Set<Int>): CacheDownloadCandidate? {
|
||||||
|
while (indices.isNotEmpty()) {
|
||||||
|
val index = indices.first()
|
||||||
|
indices.remove(index)
|
||||||
|
if (index in runningIndices || index in removedIndices) continue
|
||||||
|
emittedIndices.add(index)
|
||||||
|
return CacheDownloadCandidate(bookUrl, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
while (ranges.isNotEmpty()) {
|
||||||
|
val cursor = ranges.first()
|
||||||
|
while (cursor.next <= cursor.end) {
|
||||||
|
val index = cursor.next++
|
||||||
|
if (index in removedIndices || index in runningIndices) continue
|
||||||
|
if (emittedIndices.add(index)) {
|
||||||
|
return CacheDownloadCandidate(bookUrl, index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ranges.removeFirst()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeChapter(index: Int): Boolean {
|
||||||
|
val removed = indices.remove(index) || isWaiting(index)
|
||||||
|
removedIndices.add(index)
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
ranges.clear()
|
||||||
|
indices.clear()
|
||||||
|
emittedIndices.clear()
|
||||||
|
removedIndices.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun snapshot(): CacheDownloadQueueSnapshot {
|
||||||
|
return CacheDownloadQueueSnapshot(waitingCount = waitingCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun waitingCount(): Int {
|
||||||
|
val indexCount = indices.count { it !in emittedIndices && it !in removedIndices }
|
||||||
|
val rangeCount = ranges.sumOf { it.remainingCount(emittedIndices, removedIndices) }
|
||||||
|
return indexCount + rangeCount
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isWaiting(index: Int): Boolean {
|
||||||
|
if (index in emittedIndices || index in removedIndices) return false
|
||||||
|
return indices.contains(index) || ranges.any { it.contains(index) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun waitingIndices(): Set<Int> {
|
||||||
|
return buildSet {
|
||||||
|
indices.filterTo(this) { it !in emittedIndices && it !in removedIndices }
|
||||||
|
ranges.forEach { cursor ->
|
||||||
|
for (index in cursor.next..cursor.end) {
|
||||||
|
if (index !in emittedIndices && index !in removedIndices) add(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addRange(start: Int, end: Int) {
|
||||||
|
if (end < start) return
|
||||||
|
removedIndices.removeAll { it in start..end }
|
||||||
|
ranges.add(RangeCursor(start, end))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addIndices(values: Iterable<Int>) {
|
||||||
|
values.forEach { addIndex(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addIndex(index: Int) {
|
||||||
|
emittedIndices.remove(index)
|
||||||
|
removedIndices.remove(index)
|
||||||
|
indices.add(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package io.legado.app.model.cache
|
||||||
|
|
||||||
|
import io.legado.app.data.appDb
|
||||||
|
import io.legado.app.data.entities.Book
|
||||||
|
import io.legado.app.data.entities.BookChapter
|
||||||
|
import io.legado.app.data.entities.BookSource
|
||||||
|
import io.legado.app.help.book.BookHelp
|
||||||
|
import io.legado.app.help.coroutine.Coroutine
|
||||||
|
import io.legado.app.model.webBook.WebBook
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.CoroutineStart
|
||||||
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
|
||||||
|
class CacheDownloadRepository {
|
||||||
|
|
||||||
|
fun getChapter(bookUrl: String, index: Int): BookChapter? {
|
||||||
|
return appDb.bookChapterDao.getChapter(bookUrl, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasImageContent(book: Book, chapter: BookChapter): Boolean {
|
||||||
|
return BookHelp.hasImageContent(book, chapter)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasContent(book: Book, chapter: BookChapter): Boolean {
|
||||||
|
return BookHelp.hasContent(book, chapter)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveCachedImagesTask(
|
||||||
|
scope: CoroutineScope,
|
||||||
|
context: CoroutineContext,
|
||||||
|
bookSource: BookSource,
|
||||||
|
book: Book,
|
||||||
|
chapter: BookChapter,
|
||||||
|
): Coroutine<Unit> {
|
||||||
|
return Coroutine.async(scope, context, executeContext = context) {
|
||||||
|
BookHelp.getContent(book, chapter)?.let {
|
||||||
|
BookHelp.saveImages(bookSource, book, chapter, it, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun downloadContentTask(
|
||||||
|
scope: CoroutineScope,
|
||||||
|
bookSource: BookSource,
|
||||||
|
book: Book,
|
||||||
|
chapter: BookChapter,
|
||||||
|
context: CoroutineContext,
|
||||||
|
start: CoroutineStart = CoroutineStart.LAZY,
|
||||||
|
executeContext: CoroutineContext = context,
|
||||||
|
semaphore: Semaphore? = null,
|
||||||
|
): Coroutine<String> {
|
||||||
|
return WebBook.getContent(
|
||||||
|
scope = scope,
|
||||||
|
bookSource = bookSource,
|
||||||
|
book = book,
|
||||||
|
bookChapter = chapter,
|
||||||
|
context = context,
|
||||||
|
start = start,
|
||||||
|
executeContext = executeContext,
|
||||||
|
semaphore = semaphore,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun downloadContentAwait(
|
||||||
|
bookSource: BookSource,
|
||||||
|
book: Book,
|
||||||
|
chapter: BookChapter,
|
||||||
|
): String {
|
||||||
|
return WebBook.getContentAwait(bookSource, book, chapter)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package io.legado.app.model.cache
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
|
||||||
|
class CacheDownloadStateStore {
|
||||||
|
|
||||||
|
private val _stateFlow = MutableStateFlow(CacheDownloadState())
|
||||||
|
val stateFlow = _stateFlow.asStateFlow()
|
||||||
|
|
||||||
|
val state: CacheDownloadState
|
||||||
|
get() = _stateFlow.value
|
||||||
|
|
||||||
|
fun updateBookQueue(
|
||||||
|
bookUrl: String,
|
||||||
|
waitingCount: Int,
|
||||||
|
runningIndices: Set<Int>,
|
||||||
|
) {
|
||||||
|
updateBook(bookUrl) { current ->
|
||||||
|
current.copy(
|
||||||
|
waitingCount = waitingCount,
|
||||||
|
runningIndices = runningIndices,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markSuccess(bookUrl: String, chapterIndex: Int) {
|
||||||
|
updateBook(bookUrl) { current ->
|
||||||
|
val successIndices = current.successIndices + chapterIndex
|
||||||
|
current.copy(
|
||||||
|
runningIndices = current.runningIndices - chapterIndex,
|
||||||
|
failedIndices = current.failedIndices - chapterIndex,
|
||||||
|
successIndices = successIndices,
|
||||||
|
successCount = successIndices.size,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markFailed(bookUrl: String, chapterIndex: Int) {
|
||||||
|
updateBook(bookUrl) { current ->
|
||||||
|
current.copy(
|
||||||
|
runningIndices = current.runningIndices - chapterIndex,
|
||||||
|
failedIndices = current.failedIndices + chapterIndex,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearFailure(bookUrl: String, chapterIndex: Int) {
|
||||||
|
updateBook(bookUrl) { current ->
|
||||||
|
current.copy(failedIndices = current.failedIndices - chapterIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeBook(bookUrl: String) {
|
||||||
|
_stateFlow.update { state ->
|
||||||
|
state.copy(books = state.books - bookUrl).recalculate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
_stateFlow.value = CacheDownloadState()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bookState(bookUrl: String): CacheBookDownloadState? {
|
||||||
|
return state.books[bookUrl]
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateBook(
|
||||||
|
bookUrl: String,
|
||||||
|
transform: (CacheBookDownloadState) -> CacheBookDownloadState,
|
||||||
|
) {
|
||||||
|
_stateFlow.update { state ->
|
||||||
|
val current = state.books[bookUrl] ?: CacheBookDownloadState(bookUrl)
|
||||||
|
state.copy(books = state.books + (bookUrl to transform(current))).recalculate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CacheDownloadState.recalculate(): CacheDownloadState {
|
||||||
|
val totalWaiting = books.values.sumOf { it.waitingCount }
|
||||||
|
val totalRunning = books.values.sumOf { it.runningIndices.size }
|
||||||
|
val totalFailure = books.values.sumOf { it.failedIndices.size }
|
||||||
|
val totalSuccess = books.values.sumOf { it.successCount }
|
||||||
|
return copy(
|
||||||
|
isRunning = totalWaiting > 0 || totalRunning > 0,
|
||||||
|
totalWaiting = totalWaiting,
|
||||||
|
totalRunning = totalRunning,
|
||||||
|
totalFailure = totalFailure,
|
||||||
|
totalSuccess = totalSuccess,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package io.legado.app.model.cache
|
||||||
|
|
||||||
|
import io.legado.app.data.entities.Book
|
||||||
|
import io.legado.app.data.entities.BookChapter
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
|
||||||
|
sealed interface ReadingCacheEvent {
|
||||||
|
data class ContentReady(
|
||||||
|
val book: Book,
|
||||||
|
val chapter: BookChapter,
|
||||||
|
val content: String,
|
||||||
|
val resetPageOffset: Boolean,
|
||||||
|
val canceled: Boolean,
|
||||||
|
) : ReadingCacheEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
object ReadingCacheEvents {
|
||||||
|
private val _events = MutableSharedFlow<ReadingCacheEvent>(extraBufferCapacity = 32)
|
||||||
|
val events = _events.asSharedFlow()
|
||||||
|
|
||||||
|
fun emit(event: ReadingCacheEvent) {
|
||||||
|
_events.tryEmit(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,17 +7,18 @@ import io.legado.app.R
|
|||||||
import io.legado.app.base.BaseService
|
import io.legado.app.base.BaseService
|
||||||
import io.legado.app.constant.AppConst
|
import io.legado.app.constant.AppConst
|
||||||
import io.legado.app.constant.AppLog
|
import io.legado.app.constant.AppLog
|
||||||
import io.legado.app.constant.EventBus
|
|
||||||
import io.legado.app.constant.IntentAction
|
import io.legado.app.constant.IntentAction
|
||||||
import io.legado.app.constant.NotificationId
|
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.CacheDownloadRequest
|
||||||
|
import io.legado.app.model.cache.CacheDownloadSource
|
||||||
|
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.activityPendingIntent
|
import io.legado.app.utils.activityPendingIntent
|
||||||
import io.legado.app.utils.postEvent
|
|
||||||
import io.legado.app.utils.servicePendingIntent
|
import io.legado.app.utils.servicePendingIntent
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.asCoroutineDispatcher
|
import kotlinx.coroutines.asCoroutineDispatcher
|
||||||
@@ -75,7 +76,6 @@ class CacheBookService : BaseService() {
|
|||||||
delay(1000)
|
delay(1000)
|
||||||
notificationContent = CacheBook.downloadSummary
|
notificationContent = CacheBook.downloadSummary
|
||||||
upCacheBookNotification()
|
upCacheBookNotification()
|
||||||
postEvent(EventBus.UP_DOWNLOAD, "")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,18 +111,39 @@ class CacheBookService : BaseService() {
|
|||||||
cachePool.close()
|
cachePool.close()
|
||||||
CacheBook.close()
|
CacheBook.close()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
postEvent(EventBus.UP_DOWNLOAD, "")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun addDownloadData(bookUrl: String?, indices: List<Int>) {
|
private fun addDownloadData(bookUrl: String?, indices: Iterable<Int>) {
|
||||||
bookUrl ?: return
|
bookUrl ?: return
|
||||||
if (indices.isEmpty()) return
|
val values = indices.toSet()
|
||||||
|
if (values.isEmpty()) return
|
||||||
|
addDownloadRequest(
|
||||||
|
CacheDownloadRequest(
|
||||||
|
bookUrl = bookUrl,
|
||||||
|
selection = ChapterSelection.Indices(values),
|
||||||
|
source = CacheDownloadSource.Manual,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addDownloadData(bookUrl: String?, start: Int, end: Int) {
|
||||||
|
bookUrl ?: return
|
||||||
|
if (end < start) return
|
||||||
|
addDownloadRequest(
|
||||||
|
CacheDownloadRequest(
|
||||||
|
bookUrl = bookUrl,
|
||||||
|
selection = ChapterSelection.Range(start, end),
|
||||||
|
source = CacheDownloadSource.Manual,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addDownloadRequest(request: CacheDownloadRequest) {
|
||||||
execute {
|
execute {
|
||||||
val cacheBook = CacheBook.getOrCreate(bookUrl) ?: return@execute
|
val cacheBook = CacheBook.getOrCreate(request.bookUrl) ?: return@execute
|
||||||
|
|
||||||
val book = cacheBook.book
|
val book = cacheBook.book
|
||||||
val chapterCount = appDb.bookChapterDao.getChapterCount(bookUrl)
|
val chapterCount = appDb.bookChapterDao.getChapterCount(request.bookUrl)
|
||||||
|
|
||||||
if (chapterCount == 0) {
|
if (chapterCount == 0) {
|
||||||
cacheBook.setLoading()
|
cacheBook.setLoading()
|
||||||
@@ -132,7 +153,7 @@ class CacheBookService : BaseService() {
|
|||||||
kotlin.runCatching {
|
kotlin.runCatching {
|
||||||
WebBook.getBookInfoAwait(cacheBook.bookSource, book)
|
WebBook.getBookInfoAwait(cacheBook.bookSource, book)
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
removeDownload(bookUrl)
|
removeDownload(request.bookUrl)
|
||||||
AppLog.put(
|
AppLog.put(
|
||||||
"《$name》目录为空且加载详情页失败\n${it.localizedMessage}",
|
"《$name》目录为空且加载详情页失败\n${it.localizedMessage}",
|
||||||
it,
|
it,
|
||||||
@@ -147,7 +168,7 @@ class CacheBookService : BaseService() {
|
|||||||
book.totalChapterNum = 0
|
book.totalChapterNum = 0
|
||||||
book.update()
|
book.update()
|
||||||
}
|
}
|
||||||
removeDownload(bookUrl)
|
removeDownload(request.bookUrl)
|
||||||
AppLog.put(
|
AppLog.put(
|
||||||
"《$name》目录为空且加载目录失败\n${it.localizedMessage}",
|
"《$name》目录为空且加载目录失败\n${it.localizedMessage}",
|
||||||
it,
|
it,
|
||||||
@@ -163,7 +184,7 @@ class CacheBookService : BaseService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
//添加章节到下载队列
|
//添加章节到下载队列
|
||||||
cacheBook.addDownloads(indices)
|
cacheBook.addRequest(request)
|
||||||
|
|
||||||
notificationContent = CacheBook.downloadSummary
|
notificationContent = CacheBook.downloadSummary
|
||||||
upCacheBookNotification()
|
upCacheBookNotification()
|
||||||
@@ -174,14 +195,9 @@ class CacheBookService : BaseService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun addDownloadData(bookUrl: String?, start: Int, end: Int) {
|
|
||||||
addDownloadData(bookUrl, (start..end).toList())
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private fun removeDownload(bookUrl: String?) {
|
private fun removeDownload(bookUrl: String?) {
|
||||||
CacheBook.cacheBookMap[bookUrl]?.stop()
|
CacheBook.cacheBookMap[bookUrl]?.stop()
|
||||||
postEvent(EventBus.UP_DOWNLOAD, "")
|
|
||||||
if (downloadJob == null && CacheBook.isRun) {
|
if (downloadJob == null && CacheBook.isRun) {
|
||||||
download()
|
download()
|
||||||
return
|
return
|
||||||
|
|||||||
+82
-39
@@ -19,6 +19,8 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
@@ -26,6 +28,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
|
|
||||||
data class BookCacheManageUiState(
|
data class BookCacheManageUiState(
|
||||||
@@ -94,6 +97,11 @@ class BookCacheManageViewModel(
|
|||||||
private val clearBookCacheUseCase: ClearBookCacheUseCase,
|
private val clearBookCacheUseCase: ClearBookCacheUseCase,
|
||||||
) : BaseViewModel(application) {
|
) : BaseViewModel(application) {
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val DOWNLOAD_BATCH_SIZE = 512
|
||||||
|
const val DOWNLOAD_STATUS_REFRESH_INTERVAL_MILLIS = 2_000L
|
||||||
|
}
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(BookCacheManageUiState())
|
private val _uiState = MutableStateFlow(BookCacheManageUiState())
|
||||||
val uiState = _uiState.asStateFlow()
|
val uiState = _uiState.asStateFlow()
|
||||||
|
|
||||||
@@ -103,6 +111,9 @@ class BookCacheManageViewModel(
|
|||||||
private var observeJob: Job? = null
|
private var observeJob: Job? = null
|
||||||
private var fullReloadJob: Job? = null
|
private var fullReloadJob: Job? = null
|
||||||
private val bookReloadJobs = hashMapOf<String, Job>()
|
private val bookReloadJobs = hashMapOf<String, Job>()
|
||||||
|
private val pendingDownloadRefreshBookUrls = ConcurrentHashMap.newKeySet<String>()
|
||||||
|
@Volatile
|
||||||
|
private var pendingDownloadSummaryRefresh = false
|
||||||
|
|
||||||
fun onIntent(intent: BookCacheManageIntent) {
|
fun onIntent(intent: BookCacheManageIntent) {
|
||||||
when (intent) {
|
when (intent) {
|
||||||
@@ -140,32 +151,24 @@ class BookCacheManageViewModel(
|
|||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
CacheBook.cacheSuccessFlow.collect { chapter ->
|
CacheBook.cacheSuccessFlow.collect { chapter ->
|
||||||
scheduleBookReload(chapter.bookUrl)
|
scheduleDownloadStatusRefresh(chapter.bookUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
CacheBook.downloadingIndicesFlow.collect { state ->
|
CacheBook.downloadStateFlow.collect { state ->
|
||||||
scheduleBookReload(state.first)
|
state.books.keys.forEach { scheduleDownloadStatusRefresh(it) }
|
||||||
|
pendingDownloadSummaryRefresh = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
CacheBook.queueChangedFlow.collect { bookUrl ->
|
CacheBook.queueChangedFlow.collect { bookUrl ->
|
||||||
scheduleBookReload(bookUrl)
|
scheduleDownloadStatusRefresh(bookUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
CacheBook.downloadErrorFlow.collect { state ->
|
while (isActive) {
|
||||||
scheduleBookReload(state.first)
|
delay(DOWNLOAD_STATUS_REFRESH_INTERVAL_MILLIS)
|
||||||
}
|
flushDownloadStatusRefresh()
|
||||||
}
|
|
||||||
viewModelScope.launch {
|
|
||||||
CacheBook.downloadSummaryFlow.collect {
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(
|
|
||||||
downloadSummary = buildDownloadSummary(it.shelfBooks + it.notShelfBooks),
|
|
||||||
version = it.version + 1,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,6 +219,31 @@ class BookCacheManageViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun scheduleDownloadStatusRefresh(bookUrl: String) {
|
||||||
|
if (bookUrl.isNotBlank()) {
|
||||||
|
pendingDownloadRefreshBookUrls.add(bookUrl)
|
||||||
|
}
|
||||||
|
pendingDownloadSummaryRefresh = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun flushDownloadStatusRefresh() {
|
||||||
|
val bookUrls = pendingDownloadRefreshBookUrls.toList()
|
||||||
|
bookUrls.forEach { pendingDownloadRefreshBookUrls.remove(it) }
|
||||||
|
val shouldRefreshSummary = pendingDownloadSummaryRefresh || bookUrls.isNotEmpty()
|
||||||
|
pendingDownloadSummaryRefresh = false
|
||||||
|
bookUrls.forEach { bookUrl ->
|
||||||
|
reloadBook(bookUrl)
|
||||||
|
}
|
||||||
|
if (shouldRefreshSummary) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
downloadSummary = buildDownloadSummary(it.shelfBooks + it.notShelfBooks),
|
||||||
|
version = it.version + 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun scheduleBookReload(bookUrl: String, debounceMillis: Long = 80) {
|
private fun scheduleBookReload(bookUrl: String, debounceMillis: Long = 80) {
|
||||||
if (bookUrl.isBlank()) return
|
if (bookUrl.isBlank()) return
|
||||||
bookReloadJobs.remove(bookUrl)?.cancel()
|
bookReloadJobs.remove(bookUrl)?.cancel()
|
||||||
@@ -278,8 +306,7 @@ 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 model = CacheBook.cacheBookMap[book.bookUrl]
|
val model = CacheBook.cacheBookMap[book.bookUrl]
|
||||||
val waitingIndices = model?.waitingIndices().orEmpty()
|
val (waitingCount, downloadingCount) = model?.queueCounts() ?: (0 to 0)
|
||||||
val downloadingIndices = model?.downloadingIndices().orEmpty()
|
|
||||||
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") }
|
||||||
@@ -293,8 +320,8 @@ class BookCacheManageViewModel(
|
|||||||
author = book.getRealAuthor(),
|
author = book.getRealAuthor(),
|
||||||
totalCount = totalCount,
|
totalCount = totalCount,
|
||||||
cachedCount = cachedCount,
|
cachedCount = cachedCount,
|
||||||
waitingCount = waitingIndices.size,
|
waitingCount = waitingCount,
|
||||||
downloadingCount = downloadingIndices.size,
|
downloadingCount = downloadingCount,
|
||||||
errorCount = errorIndices.size,
|
errorCount = errorIndices.size,
|
||||||
isNotShelf = book.isNotShelf,
|
isNotShelf = book.isNotShelf,
|
||||||
)
|
)
|
||||||
@@ -305,8 +332,6 @@ class BookCacheManageViewModel(
|
|||||||
val chapters = bookChapterDao.getChapterCacheInfoList(bookUrl)
|
val chapters = bookChapterDao.getChapterCacheInfoList(bookUrl)
|
||||||
val cacheFiles = BookHelp.getChapterFiles(book)
|
val cacheFiles = BookHelp.getChapterFiles(book)
|
||||||
val model = CacheBook.cacheBookMap[bookUrl]
|
val model = CacheBook.cacheBookMap[bookUrl]
|
||||||
val waitingIndices = model?.waitingIndices().orEmpty()
|
|
||||||
val downloadingIndices = model?.downloadingIndices().orEmpty()
|
|
||||||
val errorIndices = errorIndices(bookUrl)
|
val errorIndices = errorIndices(bookUrl)
|
||||||
return chapters.map { chapter ->
|
return chapters.map { chapter ->
|
||||||
BookCacheChapterItem(
|
BookCacheChapterItem(
|
||||||
@@ -314,8 +339,8 @@ class BookCacheManageViewModel(
|
|||||||
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 = waitingIndices.contains(chapter.index),
|
isWaiting = model?.isWaiting(chapter.index) == true,
|
||||||
isDownloading = downloadingIndices.contains(chapter.index),
|
isDownloading = model?.isDownloading(chapter.index) == true,
|
||||||
isError = errorIndices.contains(chapter.index),
|
isError = errorIndices.contains(chapter.index),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -377,10 +402,14 @@ class BookCacheManageViewModel(
|
|||||||
private fun startAllDownloads() {
|
private fun startAllDownloads() {
|
||||||
val items = uiState.value.shelfBooks + uiState.value.notShelfBooks
|
val items = uiState.value.shelfBooks + uiState.value.notShelfBooks
|
||||||
execute {
|
execute {
|
||||||
items.sumOf { item ->
|
var count = 0
|
||||||
val chapterIndices = downloadableChapterIndices(item.bookUrl)
|
items.forEach { item ->
|
||||||
cacheBookChaptersUseCase.execute(item.bookUrl, chapterIndices)
|
downloadableChapterIndexBatches(item.bookUrl).forEach { chapterIndices ->
|
||||||
|
count += cacheBookChaptersUseCase.execute(item.bookUrl, chapterIndices)
|
||||||
}
|
}
|
||||||
|
currentCoroutineContext().ensureActive()
|
||||||
|
}
|
||||||
|
count
|
||||||
}.onSuccess { count ->
|
}.onSuccess { count ->
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
_effects.tryEmit(BookCacheManageEffect.ShowMessage("已加入缓存队列: $count 章"))
|
_effects.tryEmit(BookCacheManageEffect.ShowMessage("已加入缓存队列: $count 章"))
|
||||||
@@ -396,7 +425,11 @@ class BookCacheManageViewModel(
|
|||||||
|
|
||||||
private fun startBookDownload(bookUrl: String) {
|
private fun startBookDownload(bookUrl: String) {
|
||||||
execute {
|
execute {
|
||||||
cacheBookChaptersUseCase.execute(bookUrl, downloadableChapterIndices(bookUrl))
|
var count = 0
|
||||||
|
downloadableChapterIndexBatches(bookUrl).forEach { chapterIndices ->
|
||||||
|
count += cacheBookChaptersUseCase.execute(bookUrl, chapterIndices)
|
||||||
|
}
|
||||||
|
count
|
||||||
}.onSuccess { count ->
|
}.onSuccess { count ->
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
_effects.tryEmit(BookCacheManageEffect.ShowMessage("已加入缓存队列: $count 章"))
|
_effects.tryEmit(BookCacheManageEffect.ShowMessage("已加入缓存队列: $count 章"))
|
||||||
@@ -410,22 +443,32 @@ class BookCacheManageViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun downloadableChapterIndices(bookUrl: String): List<Int> {
|
private fun downloadableChapterIndexBatches(
|
||||||
val book = bookDao.getBook(bookUrl) ?: return emptyList()
|
bookUrl: String,
|
||||||
|
batchSize: Int = DOWNLOAD_BATCH_SIZE,
|
||||||
|
): Sequence<List<Int>> = sequence {
|
||||||
|
val book = bookDao.getBook(bookUrl) ?: return@sequence
|
||||||
val cacheFiles = BookHelp.getChapterFiles(book)
|
val cacheFiles = BookHelp.getChapterFiles(book)
|
||||||
val model = CacheBook.cacheBookMap[bookUrl]
|
val model = CacheBook.cacheBookMap[bookUrl]
|
||||||
val waitingIndices = model?.waitingIndices().orEmpty()
|
var batch = ArrayList<Int>(batchSize)
|
||||||
val downloadingIndices = model?.downloadingIndices().orEmpty()
|
for (chapter in bookChapterDao.getChapterCacheInfoList(bookUrl)) {
|
||||||
return bookChapterDao.getChapterCacheInfoList(bookUrl)
|
if (
|
||||||
.asSequence()
|
|
||||||
.filterNot { chapter ->
|
|
||||||
chapter.isVolume ||
|
chapter.isVolume ||
|
||||||
cacheFiles.contains(chapter.getFileName()) ||
|
cacheFiles.contains(chapter.getFileName()) ||
|
||||||
waitingIndices.contains(chapter.index) ||
|
model?.isWaiting(chapter.index) == true ||
|
||||||
downloadingIndices.contains(chapter.index)
|
model?.isDownloading(chapter.index) == true
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
batch.add(chapter.index)
|
||||||
|
if (batch.size == batchSize) {
|
||||||
|
yield(batch)
|
||||||
|
batch = ArrayList(batchSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (batch.isNotEmpty()) {
|
||||||
|
yield(batch)
|
||||||
}
|
}
|
||||||
.map { it.index }
|
|
||||||
.toList()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deleteBookCache(bookUrl: String) {
|
private fun deleteBookCache(bookUrl: String) {
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ import io.legado.app.utils.cnCompare
|
|||||||
import io.legado.app.utils.move
|
import io.legado.app.utils.move
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.ensureActive
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
@@ -155,6 +157,10 @@ class BookshelfManageScreenViewModel(
|
|||||||
private val updateBooksGroupUseCase: UpdateBooksGroupUseCase
|
private val updateBooksGroupUseCase: UpdateBooksGroupUseCase
|
||||||
) : BaseViewModel(application) {
|
) : BaseViewModel(application) {
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val DOWNLOAD_STATUS_REFRESH_INTERVAL_MILLIS = 2_000L
|
||||||
|
}
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(BookshelfManageScreenUiState())
|
private val _uiState = MutableStateFlow(BookshelfManageScreenUiState())
|
||||||
val uiState = _uiState.asStateFlow()
|
val uiState = _uiState.asStateFlow()
|
||||||
|
|
||||||
@@ -165,8 +171,11 @@ class BookshelfManageScreenViewModel(
|
|||||||
private var booksJob: Job? = null
|
private var booksJob: Job? = null
|
||||||
private var groupsJob: Job? = null
|
private var groupsJob: Job? = null
|
||||||
private var cacheLoadJob: Job? = null
|
private var cacheLoadJob: Job? = null
|
||||||
private val cacheCountJobs = ConcurrentHashMap<String, Job>()
|
|
||||||
private var observersStarted = false
|
private var observersStarted = false
|
||||||
|
private val pendingDownloadStatusBookUrls = ConcurrentHashMap.newKeySet<String>()
|
||||||
|
private val pendingCacheCountRefreshBookUrls = ConcurrentHashMap.newKeySet<String>()
|
||||||
|
@Volatile
|
||||||
|
private var pendingDownloadRunningRefresh = false
|
||||||
|
|
||||||
fun dispatch(intent: BookshelfManageScreenIntent) {
|
fun dispatch(intent: BookshelfManageScreenIntent) {
|
||||||
when (intent) {
|
when (intent) {
|
||||||
@@ -367,28 +376,26 @@ class BookshelfManageScreenViewModel(
|
|||||||
observersStarted = true
|
observersStarted = true
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
CacheBook.cacheSuccessFlow.collect { chapter ->
|
CacheBook.cacheSuccessFlow.collect { chapter ->
|
||||||
onChapterCached(chapter)
|
scheduleCacheCountRefresh(chapter.bookUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
CacheBook.downloadingIndicesFlow.collect { (bookUrl, _) ->
|
CacheBook.downloadStateFlow.collect { state ->
|
||||||
syncDownloadRunning()
|
state.books.keys.forEach { bookUrl ->
|
||||||
if (bookUrl.isNotEmpty()) {
|
scheduleDownloadStatusRefresh(bookUrl)
|
||||||
emitBookChanged(bookUrl)
|
|
||||||
}
|
}
|
||||||
|
scheduleDownloadStatusRefresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
CacheBook.downloadErrorFlow.collect { (bookUrl, _) ->
|
CacheBook.queueChangedFlow.collect { bookUrl ->
|
||||||
syncDownloadRunning()
|
scheduleDownloadStatusRefresh(bookUrl)
|
||||||
if (bookUrl.isNotEmpty()) {
|
|
||||||
emitBookChanged(bookUrl)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
viewModelScope.launch {
|
while (isActive) {
|
||||||
CacheBook.downloadSummaryFlow.collect {
|
delay(DOWNLOAD_STATUS_REFRESH_INTERVAL_MILLIS)
|
||||||
syncDownloadRunning()
|
flushDownloadStatusRefresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -432,8 +439,6 @@ class BookshelfManageScreenViewModel(
|
|||||||
|
|
||||||
private fun loadCacheCounts(books: List<Book>) {
|
private fun loadCacheCounts(books: List<Book>) {
|
||||||
cacheLoadJob?.cancel()
|
cacheLoadJob?.cancel()
|
||||||
cacheCountJobs.values.forEach { it.cancel() }
|
|
||||||
cacheCountJobs.clear()
|
|
||||||
cacheLoadJob = viewModelScope.launch(Dispatchers.IO) {
|
cacheLoadJob = viewModelScope.launch(Dispatchers.IO) {
|
||||||
val visibleBookUrls = books.mapTo(hashSetOf()) { it.bookUrl }
|
val visibleBookUrls = books.mapTo(hashSetOf()) { it.bookUrl }
|
||||||
cacheCounts.keys.toList().forEach { bookUrl ->
|
cacheCounts.keys.toList().forEach { bookUrl ->
|
||||||
@@ -451,23 +456,49 @@ class BookshelfManageScreenViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onChapterCached(chapter: BookChapter) {
|
private fun scheduleCacheCountRefresh(bookUrl: String) {
|
||||||
val bookUrl = chapter.bookUrl
|
if (bookUrl.isNotBlank()) {
|
||||||
scheduleCacheCountRefresh(bookUrl)
|
pendingCacheCountRefreshBookUrls.add(bookUrl)
|
||||||
|
}
|
||||||
|
pendingDownloadRunningRefresh = true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun scheduleCacheCountRefresh(bookUrl: String) {
|
private fun scheduleDownloadStatusRefresh(bookUrl: String = "") {
|
||||||
cacheCountJobs.remove(bookUrl)?.cancel()
|
if (bookUrl.isNotBlank()) {
|
||||||
cacheCountJobs[bookUrl] = viewModelScope.launch(Dispatchers.IO) {
|
pendingDownloadStatusBookUrls.add(bookUrl)
|
||||||
val book = bookDao.getBook(bookUrl) ?: return@launch
|
|
||||||
if (!uiState.value.books.any { it.bookUrl == bookUrl }) {
|
|
||||||
return@launch
|
|
||||||
}
|
}
|
||||||
|
pendingDownloadRunningRefresh = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun flushDownloadStatusRefresh() {
|
||||||
|
val cacheRefreshBookUrls = pendingCacheCountRefreshBookUrls.toList()
|
||||||
|
cacheRefreshBookUrls.forEach { pendingCacheCountRefreshBookUrls.remove(it) }
|
||||||
|
val statusBookUrls = pendingDownloadStatusBookUrls.toList()
|
||||||
|
statusBookUrls.forEach { pendingDownloadStatusBookUrls.remove(it) }
|
||||||
|
val shouldSyncDownloadRunning = pendingDownloadRunningRefresh ||
|
||||||
|
cacheRefreshBookUrls.isNotEmpty() ||
|
||||||
|
statusBookUrls.isNotEmpty()
|
||||||
|
pendingDownloadRunningRefresh = false
|
||||||
|
val changedBookUrls = linkedSetOf<String>()
|
||||||
|
val visibleBookUrls = uiState.value.books.mapTo(hashSetOf()) { it.bookUrl }
|
||||||
|
cacheRefreshBookUrls.forEach { bookUrl ->
|
||||||
|
if (visibleBookUrls.contains(bookUrl)) {
|
||||||
|
bookDao.getBook(bookUrl)?.let { book ->
|
||||||
cacheCounts[bookUrl] = calculateCacheCount(book)
|
cacheCounts[bookUrl] = calculateCacheCount(book)
|
||||||
emitBookChanged(bookUrl)
|
changedBookUrls.add(bookUrl)
|
||||||
cacheCountJobs.remove(bookUrl)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
statusBookUrls.forEach { bookUrl ->
|
||||||
|
if (visibleBookUrls.contains(bookUrl)) {
|
||||||
|
changedBookUrls.add(bookUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (shouldSyncDownloadRunning) {
|
||||||
|
syncDownloadRunning()
|
||||||
|
}
|
||||||
|
emitBooksChanged(changedBookUrls)
|
||||||
|
}
|
||||||
|
|
||||||
private fun calculateCacheCount(book: Book): Int {
|
private fun calculateCacheCount(book: Book): Int {
|
||||||
val cacheNames = BookHelp.getChapterFiles(book)
|
val cacheNames = BookHelp.getChapterFiles(book)
|
||||||
@@ -496,7 +527,7 @@ class BookshelfManageScreenViewModel(
|
|||||||
syncDownloadRunning()
|
syncDownloadRunning()
|
||||||
} else {
|
} else {
|
||||||
execute {
|
execute {
|
||||||
cacheBookChaptersUseCase.execute(book.bookUrl, 0..book.lastChapterIndex)
|
cacheBookChaptersUseCase.executeRange(book.bookUrl, 0, book.lastChapterIndex)
|
||||||
}.onFinally {
|
}.onFinally {
|
||||||
syncDownloadRunning()
|
syncDownloadRunning()
|
||||||
}
|
}
|
||||||
@@ -873,4 +904,12 @@ class BookshelfManageScreenViewModel(
|
|||||||
_effects.tryEmit(BookshelfManageScreenEffect.NotifyBookChanged(bookUrl))
|
_effects.tryEmit(BookshelfManageScreenEffect.NotifyBookChanged(bookUrl))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun emitBooksChanged(bookUrls: Set<String>) {
|
||||||
|
if (bookUrls.isEmpty()) return
|
||||||
|
_uiState.update { it.copy(cacheVersion = it.cacheVersion + 1) }
|
||||||
|
bookUrls.forEach { bookUrl ->
|
||||||
|
_effects.tryEmit(BookshelfManageScreenEffect.NotifyBookChanged(bookUrl))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -303,8 +303,7 @@ abstract class BaseReadBookActivity :
|
|||||||
val end = editEnd.text!!.toString().let {
|
val end = editEnd.text!!.toString().let {
|
||||||
if (it.isEmpty()) book.totalChapterNum else it.toInt()
|
if (it.isEmpty()) book.totalChapterNum else it.toInt()
|
||||||
}
|
}
|
||||||
val indices = (start - 1..end - 1).toList()
|
CacheBook.start(this@BaseReadBookActivity, book, start - 1, end - 1)
|
||||||
CacheBook.start(this@BaseReadBookActivity, book, indices)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cancelButton()
|
cancelButton()
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import io.legado.app.help.bookmark.BookmarkExporter
|
|||||||
import io.legado.app.help.config.AppConfig
|
import io.legado.app.help.config.AppConfig
|
||||||
import io.legado.app.model.CacheBook
|
import io.legado.app.model.CacheBook
|
||||||
import io.legado.app.model.ReadBook
|
import io.legado.app.model.ReadBook
|
||||||
|
import io.legado.app.model.cache.CacheBookDownloadState
|
||||||
import io.legado.app.model.localBook.LocalBook
|
import io.legado.app.model.localBook.LocalBook
|
||||||
import io.legado.app.ui.config.readConfig.ReadConfig
|
import io.legado.app.ui.config.readConfig.ReadConfig
|
||||||
import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
|
import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
|
||||||
@@ -89,8 +90,7 @@ data class TocDomainItem(
|
|||||||
)
|
)
|
||||||
|
|
||||||
private data class DownloadContext(
|
private data class DownloadContext(
|
||||||
val downloadingPair: Pair<String, Set<Int>>,
|
val downloadState: CacheBookDownloadState?,
|
||||||
val errorPair: Pair<String, Set<Int>>,
|
|
||||||
val cachedFiles: Set<String>
|
val cachedFiles: Set<String>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -206,11 +206,11 @@ class TocViewModel(
|
|||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
|
|
||||||
private val downloadContextFlow = combine(
|
private val downloadContextFlow = combine(
|
||||||
CacheBook.downloadingIndicesFlow,
|
bookState.filterNotNull().map { it.bookUrl }.distinctUntilChanged(),
|
||||||
CacheBook.downloadErrorFlow,
|
CacheBook.downloadStateFlow,
|
||||||
_cacheFileNames
|
_cacheFileNames
|
||||||
) { downloading, errors, cached ->
|
) { bookUrl, state, cached ->
|
||||||
DownloadContext(downloading, errors, cached)
|
DownloadContext(state.books[bookUrl], cached)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val uiConfigFlow = combine(
|
private val uiConfigFlow = combine(
|
||||||
@@ -263,15 +263,13 @@ class TocViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val (downloadingPair, errorPair, cachedFiles) = downloadCtx
|
val runningIndices = downloadCtx.downloadState?.runningIndices.orEmpty()
|
||||||
val downloadingIndices =
|
val errorIndices = downloadCtx.downloadState?.failedIndices.orEmpty()
|
||||||
if (downloadingPair.first == book.bookUrl) downloadingPair.second else emptySet()
|
val cachedFiles = downloadCtx.cachedFiles
|
||||||
val errorIndices =
|
|
||||||
if (errorPair.first == book.bookUrl) errorPair.second else emptySet()
|
|
||||||
|
|
||||||
processedChapters.map { chapter ->
|
processedChapters.map { chapter ->
|
||||||
val downloadState = when {
|
val downloadState = when {
|
||||||
chapter.index in downloadingIndices -> DownloadState.DOWNLOADING
|
chapter.index in runningIndices -> DownloadState.DOWNLOADING
|
||||||
chapter.index in errorIndices -> DownloadState.ERROR
|
chapter.index in errorIndices -> DownloadState.ERROR
|
||||||
chapter.getFileName() in cachedFiles -> DownloadState.SUCCESS
|
chapter.getFileName() in cachedFiles -> DownloadState.SUCCESS
|
||||||
else -> DownloadState.NONE
|
else -> DownloadState.NONE
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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 CacheDownloadQueueTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rangeReturnsChaptersLazily() {
|
||||||
|
val queue = CacheDownloadQueue()
|
||||||
|
|
||||||
|
queue.enqueue(ChapterSelection.Range(2, 4))
|
||||||
|
|
||||||
|
assertEquals(3, queue.waitingCount())
|
||||||
|
assertEquals(2, queue.next("book", emptySet())?.chapterIndex)
|
||||||
|
assertEquals(2, queue.waitingCount())
|
||||||
|
assertEquals(3, queue.next("book", emptySet())?.chapterIndex)
|
||||||
|
assertEquals(4, queue.next("book", emptySet())?.chapterIndex)
|
||||||
|
assertNull(queue.next("book", emptySet()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun removeChapterSkipsChapterInsideRange() {
|
||||||
|
val queue = CacheDownloadQueue()
|
||||||
|
|
||||||
|
queue.enqueue(ChapterSelection.Range(0, 3))
|
||||||
|
assertTrue(queue.removeChapter(1))
|
||||||
|
|
||||||
|
assertEquals(listOf(0, 2, 3), drain(queue))
|
||||||
|
assertFalse(queue.isWaiting(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nextDoesNotReturnRunningChapter() {
|
||||||
|
val queue = CacheDownloadQueue()
|
||||||
|
|
||||||
|
queue.enqueue(ChapterSelection.Indices(setOf(1, 2)))
|
||||||
|
|
||||||
|
assertEquals(2, queue.next("book", setOf(1))?.chapterIndex)
|
||||||
|
assertNull(queue.next("book", emptySet()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun explicitRequeueCanRetryConsumedChapter() {
|
||||||
|
val queue = CacheDownloadQueue()
|
||||||
|
|
||||||
|
queue.enqueue(ChapterSelection.Range(1, 1))
|
||||||
|
assertEquals(1, queue.next("book", emptySet())?.chapterIndex)
|
||||||
|
queue.enqueue(ChapterSelection.Single(1))
|
||||||
|
|
||||||
|
assertEquals(1, queue.next("book", emptySet())?.chapterIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reEnqueueRangeRestoresRemovedChapter() {
|
||||||
|
val queue = CacheDownloadQueue()
|
||||||
|
|
||||||
|
queue.enqueue(ChapterSelection.Range(0, 2))
|
||||||
|
assertTrue(queue.removeChapter(1))
|
||||||
|
assertEquals(0, queue.next("book", emptySet())?.chapterIndex)
|
||||||
|
queue.enqueue(ChapterSelection.Range(1, 1))
|
||||||
|
|
||||||
|
assertEquals(1, queue.next("book", emptySet())?.chapterIndex)
|
||||||
|
assertEquals(2, queue.next("book", emptySet())?.chapterIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drain(queue: CacheDownloadQueue): List<Int> {
|
||||||
|
val result = mutableListOf<Int>()
|
||||||
|
while (true) {
|
||||||
|
val next = queue.next("book", emptySet()) ?: break
|
||||||
|
result.add(next.chapterIndex)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package io.legado.app.model.cache
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CacheDownloadStateStoreTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun queueAndRunningCountsAreDerivedFromBooks() {
|
||||||
|
val store = CacheDownloadStateStore()
|
||||||
|
|
||||||
|
store.updateBookQueue("a", waitingCount = 2, runningIndices = setOf(1))
|
||||||
|
store.updateBookQueue("b", waitingCount = 3, runningIndices = setOf(4, 5))
|
||||||
|
|
||||||
|
val state = store.state
|
||||||
|
assertTrue(state.isRunning)
|
||||||
|
assertEquals(5, state.totalWaiting)
|
||||||
|
assertEquals(3, state.totalRunning)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun successAndFailureUpdateBookState() {
|
||||||
|
val store = CacheDownloadStateStore()
|
||||||
|
|
||||||
|
store.updateBookQueue("a", waitingCount = 0, runningIndices = setOf(1, 2))
|
||||||
|
store.markFailed("a", 1)
|
||||||
|
store.markSuccess("a", 2)
|
||||||
|
|
||||||
|
val bookState = store.state.books.getValue("a")
|
||||||
|
assertEquals(setOf(1), bookState.failedIndices)
|
||||||
|
assertEquals(emptySet<Int>(), bookState.runningIndices)
|
||||||
|
assertEquals(1, bookState.successCount)
|
||||||
|
assertEquals(1, store.state.totalFailure)
|
||||||
|
assertEquals(1, store.state.totalSuccess)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun duplicateSuccessDoesNotInflateCount() {
|
||||||
|
val store = CacheDownloadStateStore()
|
||||||
|
|
||||||
|
store.markSuccess("a", 1)
|
||||||
|
store.markSuccess("a", 1)
|
||||||
|
|
||||||
|
assertEquals(1, store.state.books.getValue("a").successCount)
|
||||||
|
assertEquals(1, store.state.totalSuccess)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun removeBookRecalculatesRunningState() {
|
||||||
|
val store = CacheDownloadStateStore()
|
||||||
|
|
||||||
|
store.updateBookQueue("a", waitingCount = 1, runningIndices = emptySet())
|
||||||
|
store.removeBook("a")
|
||||||
|
|
||||||
|
assertFalse(store.state.isRunning)
|
||||||
|
assertEquals(0, store.state.totalWaiting)
|
||||||
|
assertEquals(emptyMap<String, CacheBookDownloadState>(), store.state.books)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user