[优化] 依旧优化某些界面的OOM问题
This commit is contained in:
@@ -35,6 +35,9 @@ interface BookChapterDao {
|
|||||||
@Query("select count(url) from chapters where bookUrl = :bookUrl")
|
@Query("select count(url) from chapters where bookUrl = :bookUrl")
|
||||||
fun getChapterCount(bookUrl: String): Int
|
fun getChapterCount(bookUrl: String): Int
|
||||||
|
|
||||||
|
@Query("select count(url) from chapters where bookUrl = :bookUrl and isVolume = 1")
|
||||||
|
fun getVolumeCount(bookUrl: String): Int
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
fun insert(vararg bookChapter: BookChapter)
|
fun insert(vararg bookChapter: BookChapter)
|
||||||
|
|
||||||
@@ -59,4 +62,4 @@ interface BookChapterDao {
|
|||||||
AND `index` = :chapterIndex
|
AND `index` = :chapterIndex
|
||||||
""")
|
""")
|
||||||
suspend fun getChapterTitleByUrlAndIndex(bookUrl: String, chapterIndex: Int): String?
|
suspend fun getChapterTitleByUrlAndIndex(bookUrl: String, chapterIndex: Int): String?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ interface SearchBookDao {
|
|||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
fun insert(vararg searchBook: SearchBook): List<Long>
|
fun insert(vararg searchBook: SearchBook): List<Long>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
fun insert(searchBooks: List<SearchBook>): List<Long>
|
||||||
|
|
||||||
@Query("delete from searchBooks where name = :name and author = :author")
|
@Query("delete from searchBooks where name = :name and author = :author")
|
||||||
fun clear(name: String, author: String)
|
fun clear(name: String, author: String)
|
||||||
|
|
||||||
@@ -70,4 +73,4 @@ interface SearchBookDao {
|
|||||||
|
|
||||||
@Delete
|
@Delete
|
||||||
fun delete(vararg searchBook: SearchBook)
|
fun delete(vararg searchBook: SearchBook)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ sealed interface SearchSessionEvent {
|
|||||||
data object Started : SearchSessionEvent
|
data object Started : SearchSessionEvent
|
||||||
|
|
||||||
data class Progress(
|
data class Progress(
|
||||||
val books: List<SearchBook>,
|
val upsertBooks: List<SearchBook>,
|
||||||
|
val removedBookUrls: List<String>,
|
||||||
|
val resultCount: Int,
|
||||||
val processedSources: Int,
|
val processedSources: Int,
|
||||||
val totalSources: Int,
|
val totalSources: Int,
|
||||||
) : SearchSessionEvent
|
) : SearchSessionEvent
|
||||||
@@ -120,32 +122,36 @@ class SearchRepositoryImpl(
|
|||||||
) : SearchSession {
|
) : SearchSession {
|
||||||
|
|
||||||
private val sessionScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val sessionScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private val _events = MutableSharedFlow<SearchSessionEvent>(extraBufferCapacity = 64)
|
private val _events = MutableSharedFlow<SearchSessionEvent>(extraBufferCapacity = 8)
|
||||||
override val events: Flow<SearchSessionEvent> = _events.asSharedFlow()
|
override val events: Flow<SearchSessionEvent> = _events.asSharedFlow()
|
||||||
|
|
||||||
private val searchModel = SearchModel(sessionScope, object : SearchModel.CallBack {
|
private val searchModel = SearchModel(sessionScope, object : SearchModel.CallBack {
|
||||||
override fun getSearchScope(): SearchScope = scopeProvider()
|
override fun getSearchScope(): SearchScope = scopeProvider()
|
||||||
|
|
||||||
override fun onSearchStart() {
|
override suspend fun onSearchStart() {
|
||||||
_events.tryEmit(SearchSessionEvent.Started)
|
_events.emit(SearchSessionEvent.Started)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSearchSuccess(
|
override suspend fun onSearchSuccess(
|
||||||
searchBooks: List<SearchBook>,
|
upsertBooks: List<SearchBook>,
|
||||||
|
removedBookUrls: List<String>,
|
||||||
|
resultCount: Int,
|
||||||
processedSources: Int,
|
processedSources: Int,
|
||||||
totalSources: Int,
|
totalSources: Int,
|
||||||
) {
|
) {
|
||||||
_events.tryEmit(
|
_events.emit(
|
||||||
SearchSessionEvent.Progress(
|
SearchSessionEvent.Progress(
|
||||||
books = searchBooks.toList(),
|
upsertBooks = upsertBooks,
|
||||||
|
removedBookUrls = removedBookUrls,
|
||||||
|
resultCount = resultCount,
|
||||||
processedSources = processedSources,
|
processedSources = processedSources,
|
||||||
totalSources = totalSources,
|
totalSources = totalSources,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSearchFinish(isEmpty: Boolean, hasMore: Boolean) {
|
override suspend fun onSearchFinish(isEmpty: Boolean, hasMore: Boolean) {
|
||||||
_events.tryEmit(SearchSessionEvent.Finished(isEmpty, hasMore))
|
_events.emit(SearchSessionEvent.Finished(isEmpty, hasMore))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSearchCancel(exception: Throwable?) {
|
override fun onSearchCancel(exception: Throwable?) {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ data class BatchChangeBookSourceResult(
|
|||||||
data class BatchChangeSourceCandidate(
|
data class BatchChangeSourceCandidate(
|
||||||
val source: BookSource,
|
val source: BookSource,
|
||||||
val book: Book,
|
val book: Book,
|
||||||
val chapters: List<BookChapter>,
|
val chapterCount: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class BatchChangeSourcePreviewItem(
|
data class BatchChangeSourcePreviewItem(
|
||||||
@@ -172,14 +172,8 @@ class ChangeBookSourceUseCase(
|
|||||||
} else {
|
} else {
|
||||||
val candidates = arrayListOf<BatchChangeSourceCandidate>()
|
val candidates = arrayListOf<BatchChangeSourceCandidate>()
|
||||||
sources.filterNot { it.bookSourceUrl == book.origin }.forEach { source ->
|
sources.filterNot { it.bookSourceUrl == book.origin }.forEach { source ->
|
||||||
findBookInSource(book, source)?.let { (newBook, chapters) ->
|
findBookInSource(book, source)?.let { candidate ->
|
||||||
candidates.add(
|
candidates.add(candidate)
|
||||||
BatchChangeSourceCandidate(
|
|
||||||
source = source,
|
|
||||||
book = newBook,
|
|
||||||
chapters = chapters,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (candidates.isEmpty()) {
|
if (candidates.isEmpty()) {
|
||||||
@@ -199,50 +193,40 @@ class ChangeBookSourceUseCase(
|
|||||||
.map { it.second }
|
.map { it.second }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun changePreviewItems(
|
|
||||||
items: List<BatchChangeSourcePreviewItem>,
|
|
||||||
options: ChangeSourceMigrationOptions,
|
|
||||||
): BatchChangeBookSourceResult {
|
|
||||||
var changedCount = 0
|
|
||||||
var failedCount = 0
|
|
||||||
var skippedCount = 0
|
|
||||||
items.forEach { item ->
|
|
||||||
if (item.status == BatchChangeSourcePreviewStatus.Skipped) {
|
|
||||||
skippedCount++
|
|
||||||
return@forEach
|
|
||||||
}
|
|
||||||
val candidate = item.selectedCandidate
|
|
||||||
if (candidate == null) {
|
|
||||||
failedCount++
|
|
||||||
return@forEach
|
|
||||||
}
|
|
||||||
changeTo(item.oldBook, candidate.book, candidate.chapters, options)
|
|
||||||
changedCount++
|
|
||||||
}
|
|
||||||
return BatchChangeBookSourceResult(changedCount, failedCount, skippedCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun findBookInSource(
|
private suspend fun findBookInSource(
|
||||||
oldBook: Book,
|
oldBook: Book,
|
||||||
source: BookSource,
|
source: BookSource,
|
||||||
): Pair<Book, List<BookChapter>>? {
|
): BatchChangeSourceCandidate? {
|
||||||
val newBook = WebBook.preciseSearchAwait(source, oldBook.name, oldBook.author)
|
val newBook = WebBook.preciseSearchAwait(source, oldBook.name, oldBook.author)
|
||||||
.onFailure {
|
.onFailure {
|
||||||
AppLog.put("搜索书籍出错\n${it.localizedMessage}", it, true)
|
AppLog.put("搜索书籍出错\n${it.localizedMessage}", it, true)
|
||||||
}.getOrNull() ?: return null
|
}.getOrNull() ?: return null
|
||||||
|
val chapters = loadCandidateChapters(source, newBook) ?: return null
|
||||||
|
return BatchChangeSourceCandidate(
|
||||||
|
source = source,
|
||||||
|
book = newBook,
|
||||||
|
chapterCount = chapters.size,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun loadCandidateChapters(
|
||||||
|
source: BookSource,
|
||||||
|
book: Book,
|
||||||
|
): List<BookChapter>? {
|
||||||
val infoLoaded = kotlin.runCatching {
|
val infoLoaded = kotlin.runCatching {
|
||||||
if (newBook.tocUrl.isEmpty()) {
|
if (book.tocUrl.isEmpty()) {
|
||||||
WebBook.getBookInfoAwait(source, newBook)
|
WebBook.getBookInfoAwait(source, book)
|
||||||
}
|
}
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
AppLog.put("获取书籍详情出错\n${it.localizedMessage}", it, true)
|
AppLog.put("获取书籍详情出错\n${it.localizedMessage}", it, true)
|
||||||
}.isSuccess
|
}.isSuccess
|
||||||
if (!infoLoaded) return null
|
if (!infoLoaded) return null
|
||||||
val chapters = WebBook.getChapterListAwait(source, newBook)
|
val chapters = WebBook.getChapterListAwait(source, book)
|
||||||
.onFailure {
|
.onFailure {
|
||||||
AppLog.put("获取目录出错\n${it.localizedMessage}", it, true)
|
AppLog.put("获取目录出错\n${it.localizedMessage}", it, true)
|
||||||
}.getOrNull() ?: return null
|
}.getOrNull() ?: return null
|
||||||
return newBook to chapters
|
book.totalChapterNum = chapters.size
|
||||||
|
return chapters
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Book.applyMigrationTo(
|
private fun Book.applyMigrationTo(
|
||||||
|
|||||||
@@ -100,6 +100,9 @@ object CacheBook {
|
|||||||
MutableStateFlow<Pair<String, Set<Int>>>("" to emptySet())
|
MutableStateFlow<Pair<String, Set<Int>>>("" to emptySet())
|
||||||
val downloadingIndicesFlow = _downloadingIndicesFlow.asStateFlow()
|
val downloadingIndicesFlow = _downloadingIndicesFlow.asStateFlow()
|
||||||
|
|
||||||
|
private val _queueChangedFlow = MutableSharedFlow<String>(extraBufferCapacity = 64)
|
||||||
|
val queueChangedFlow = _queueChangedFlow.asSharedFlow()
|
||||||
|
|
||||||
private val _downloadErrorFlow =
|
private val _downloadErrorFlow =
|
||||||
MutableStateFlow<Pair<String, Set<Int>>>("" to emptySet())
|
MutableStateFlow<Pair<String, Set<Int>>>("" to emptySet())
|
||||||
val downloadErrorFlow = _downloadErrorFlow.asStateFlow()
|
val downloadErrorFlow = _downloadErrorFlow.asStateFlow()
|
||||||
@@ -113,6 +116,10 @@ object CacheBook {
|
|||||||
val cacheBookMap: ConcurrentHashMap<String, CacheBookModel>
|
val cacheBookMap: ConcurrentHashMap<String, CacheBookModel>
|
||||||
get() = coordinator.taskMap
|
get() = coordinator.taskMap
|
||||||
|
|
||||||
|
fun errorIndices(bookUrl: String): Set<Int> {
|
||||||
|
return errorIndexMap[bookUrl]?.toSet().orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
private fun collectQueueStats(): QueueStats {
|
private fun collectQueueStats(): QueueStats {
|
||||||
var waiting = 0
|
var waiting = 0
|
||||||
var downloading = 0
|
var downloading = 0
|
||||||
@@ -222,6 +229,7 @@ object CacheBook {
|
|||||||
|
|
||||||
private fun onTaskQueuesChanged(bookUrl: String) {
|
private fun onTaskQueuesChanged(bookUrl: String) {
|
||||||
updateSummary()
|
updateSummary()
|
||||||
|
_queueChangedFlow.tryEmit(bookUrl)
|
||||||
postEvent(EventBus.UP_DOWNLOAD, bookUrl)
|
postEvent(EventBus.UP_DOWNLOAD, bookUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,8 +305,13 @@ object CacheBook {
|
|||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun addDownload(start: Int, end: Int) {
|
fun addDownload(start: Int, end: Int) {
|
||||||
|
addDownloads(start..end)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun addDownloads(indices: Iterable<Int>) {
|
||||||
isStopped = false
|
isStopped = false
|
||||||
for (i in start..end) {
|
for (i in indices) {
|
||||||
if (!onDownloadSet.contains(i)) {
|
if (!onDownloadSet.contains(i)) {
|
||||||
waitDownloadSet.add(i)
|
waitDownloadSet.add(i)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,15 +33,22 @@ import kotlin.coroutines.coroutineContext
|
|||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
|
|
||||||
class SearchModel(private val scope: CoroutineScope, private val callBack: CallBack) {
|
class SearchModel(private val scope: CoroutineScope, private val callBack: CallBack) {
|
||||||
|
private companion object {
|
||||||
|
const val MAX_RETAINED_SEARCH_RESULTS = 1000
|
||||||
|
}
|
||||||
|
|
||||||
val threadCount = OtherConfig.threadCount
|
val threadCount = OtherConfig.threadCount
|
||||||
private var searchPool: ExecutorCoroutineDispatcher? = null
|
private var searchPool: ExecutorCoroutineDispatcher? = null
|
||||||
private var mSearchId = 0L
|
private var mSearchId = 0L
|
||||||
private var searchPage = 1
|
private var searchPage = 1
|
||||||
private var searchKey: String = ""
|
private var searchKey: String = ""
|
||||||
private var bookSourceParts = emptyList<BookSourcePart>()
|
private var bookSourceParts = emptyList<BookSourcePart>()
|
||||||
private var searchBooks = arrayListOf<SearchBook>()
|
private val equalBooks = LinkedHashMap<SearchBookKey, SearchBook>()
|
||||||
|
private val containsBooks = LinkedHashMap<SearchBookKey, SearchBook>()
|
||||||
|
private val otherBooks = LinkedHashMap<SearchBookKey, SearchBook>()
|
||||||
private var searchJob: Job? = null
|
private var searchJob: Job? = null
|
||||||
private var workingState = MutableStateFlow(true)
|
private var workingState = MutableStateFlow(true)
|
||||||
|
private var resultLimitReached = false
|
||||||
|
|
||||||
private fun initSearchPool() {
|
private fun initSearchPool() {
|
||||||
searchPool?.close()
|
searchPool?.close()
|
||||||
@@ -58,7 +65,7 @@ class SearchModel(private val scope: CoroutineScope, private val callBack: CallB
|
|||||||
if (mSearchId != 0L) {
|
if (mSearchId != 0L) {
|
||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
searchBooks.clear()
|
clearSearchBooks()
|
||||||
bookSourceParts = callBack.getSearchScope().getBookSourceParts()
|
bookSourceParts = callBack.getSearchScope().getBookSourceParts()
|
||||||
if (bookSourceParts.isEmpty()) {
|
if (bookSourceParts.isEmpty()) {
|
||||||
callBack.onSearchCancel(NoStackTraceException("启用书源为空"))
|
callBack.onSearchCancel(NoStackTraceException("启用书源为空"))
|
||||||
@@ -102,84 +109,100 @@ class SearchModel(private val scope: CoroutineScope, private val callBack: CallB
|
|||||||
book.releaseHtmlData()
|
book.releaseHtmlData()
|
||||||
}
|
}
|
||||||
hasMore = hasMore || items.isNotEmpty()
|
hasMore = hasMore || items.isNotEmpty()
|
||||||
appDb.searchBookDao.insert(*items.toTypedArray())
|
if (items.isNotEmpty()) {
|
||||||
mergeItems(items, precision)
|
appDb.searchBookDao.insert(items)
|
||||||
|
}
|
||||||
|
val change = mergeItems(items, precision)
|
||||||
currentCoroutineContext().ensureActive()
|
currentCoroutineContext().ensureActive()
|
||||||
processedParts++
|
processedParts++
|
||||||
callBack.onSearchSuccess(searchBooks, processedParts, totalParts)
|
callBack.onSearchSuccess(
|
||||||
|
upsertBooks = change.upsertBooks,
|
||||||
|
removedBookUrls = change.removedBookUrls,
|
||||||
|
resultCount = searchBookCount(),
|
||||||
|
processedSources = processedParts,
|
||||||
|
totalSources = totalParts,
|
||||||
|
)
|
||||||
}.onCompletion {
|
}.onCompletion {
|
||||||
if (it == null) callBack.onSearchFinish(searchBooks.isEmpty(), hasMore)
|
if (it == null) {
|
||||||
|
callBack.onSearchFinish(
|
||||||
|
isEmpty = searchBookCount() == 0,
|
||||||
|
hasMore = hasMore && !resultLimitReached,
|
||||||
|
)
|
||||||
|
}
|
||||||
}.catch {
|
}.catch {
|
||||||
AppLog.put("书源搜索出错\n${it.localizedMessage}", it)
|
AppLog.put("书源搜索出错\n${it.localizedMessage}", it)
|
||||||
}.collect()
|
}.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun mergeItems(newDataS: List<SearchBook>, precision: Boolean) {
|
private suspend fun mergeItems(newDataS: List<SearchBook>, precision: Boolean): SearchBookChange {
|
||||||
if (newDataS.isNotEmpty()) {
|
if (newDataS.isEmpty()) {
|
||||||
val copyData = ArrayList(searchBooks)
|
return SearchBookChange()
|
||||||
val equalData = arrayListOf<SearchBook>()
|
|
||||||
val containsData = arrayListOf<SearchBook>()
|
|
||||||
val otherData = arrayListOf<SearchBook>()
|
|
||||||
copyData.forEach {
|
|
||||||
coroutineContext.ensureActive()
|
|
||||||
if (it.name == searchKey || it.author == searchKey) {
|
|
||||||
equalData.add(it)
|
|
||||||
} else if (it.name.contains(searchKey) || it.author.contains(searchKey)) {
|
|
||||||
containsData.add(it)
|
|
||||||
} else {
|
|
||||||
otherData.add(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
newDataS.forEach { nBook ->
|
|
||||||
coroutineContext.ensureActive()
|
|
||||||
if (nBook.name == searchKey || nBook.author == searchKey) {
|
|
||||||
var hasSame = false
|
|
||||||
equalData.forEach { pBook ->
|
|
||||||
coroutineContext.ensureActive()
|
|
||||||
if (pBook.name == nBook.name && pBook.author == nBook.author) {
|
|
||||||
pBook.addOrigin(nBook.origin)
|
|
||||||
hasSame = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!hasSame) {
|
|
||||||
equalData.add(nBook)
|
|
||||||
}
|
|
||||||
} else if (nBook.name.contains(searchKey) || nBook.author.contains(searchKey)) {
|
|
||||||
var hasSame = false
|
|
||||||
containsData.forEach { pBook ->
|
|
||||||
coroutineContext.ensureActive()
|
|
||||||
if (pBook.name == nBook.name && pBook.author == nBook.author) {
|
|
||||||
pBook.addOrigin(nBook.origin)
|
|
||||||
hasSame = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!hasSame) {
|
|
||||||
containsData.add(nBook)
|
|
||||||
}
|
|
||||||
} else if (!precision) {
|
|
||||||
var hasSame = false
|
|
||||||
otherData.forEach { pBook ->
|
|
||||||
coroutineContext.ensureActive()
|
|
||||||
if (pBook.name == nBook.name && pBook.author == nBook.author) {
|
|
||||||
pBook.addOrigin(nBook.origin)
|
|
||||||
hasSame = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!hasSame) {
|
|
||||||
otherData.add(nBook)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
coroutineContext.ensureActive()
|
|
||||||
equalData.sortByDescending { it.origins.size }
|
|
||||||
equalData.addAll(containsData.sortedByDescending { it.origins.size })
|
|
||||||
if (!precision) {
|
|
||||||
equalData.addAll(otherData)
|
|
||||||
}
|
|
||||||
coroutineContext.ensureActive()
|
|
||||||
searchBooks = equalData
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val upsertBooks = arrayListOf<SearchBook>()
|
||||||
|
val removedBookUrls = linkedSetOf<String>()
|
||||||
|
newDataS.forEach { nBook ->
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
val bucket = classifyBucket(nBook, precision) ?: return@forEach
|
||||||
|
val key = SearchBookKey(nBook.name, nBook.author)
|
||||||
|
val currentBook = bucket[key]
|
||||||
|
if (currentBook == null) {
|
||||||
|
bucket[key] = nBook
|
||||||
|
upsertBooks.add(nBook)
|
||||||
|
} else {
|
||||||
|
currentBook.addOrigin(nBook.origin)
|
||||||
|
upsertBooks.add(currentBook)
|
||||||
|
}
|
||||||
|
trimSearchBooks()?.let { removed ->
|
||||||
|
removedBookUrls.add(removed.bookUrl)
|
||||||
|
upsertBooks.removeAll { it.bookUrl == removed.bookUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SearchBookChange(upsertBooks, removedBookUrls.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun classifyBucket(
|
||||||
|
book: SearchBook,
|
||||||
|
precision: Boolean,
|
||||||
|
): LinkedHashMap<SearchBookKey, SearchBook>? {
|
||||||
|
return when {
|
||||||
|
book.name == searchKey || book.author == searchKey -> equalBooks
|
||||||
|
book.name.contains(searchKey) || book.author.contains(searchKey) -> containsBooks
|
||||||
|
!precision -> otherBooks
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun trimSearchBooks(): SearchBook? {
|
||||||
|
if (searchBookCount() <= MAX_RETAINED_SEARCH_RESULTS) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
resultLimitReached = true
|
||||||
|
return removeLast(otherBooks)
|
||||||
|
?: removeLowestOrigin(containsBooks)
|
||||||
|
?: removeLowestOrigin(equalBooks)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeLast(bucket: LinkedHashMap<SearchBookKey, SearchBook>): SearchBook? {
|
||||||
|
val key = bucket.keys.lastOrNull() ?: return null
|
||||||
|
return bucket.remove(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeLowestOrigin(bucket: LinkedHashMap<SearchBookKey, SearchBook>): SearchBook? {
|
||||||
|
val key = bucket.entries.minByOrNull { it.value.origins.size }?.key ?: return null
|
||||||
|
return bucket.remove(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun searchBookCount(): Int {
|
||||||
|
return equalBooks.size + containsBooks.size + otherBooks.size
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearSearchBooks() {
|
||||||
|
equalBooks.clear()
|
||||||
|
containsBooks.clear()
|
||||||
|
otherBooks.clear()
|
||||||
|
resultLimitReached = false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelSearch() {
|
fun cancelSearch() {
|
||||||
@@ -204,10 +227,26 @@ class SearchModel(private val scope: CoroutineScope, private val callBack: CallB
|
|||||||
|
|
||||||
interface CallBack {
|
interface CallBack {
|
||||||
fun getSearchScope(): SearchScope
|
fun getSearchScope(): SearchScope
|
||||||
fun onSearchStart()
|
suspend fun onSearchStart()
|
||||||
fun onSearchSuccess(searchBooks: List<SearchBook>, processedSources: Int, totalSources: Int)
|
suspend fun onSearchSuccess(
|
||||||
fun onSearchFinish(isEmpty: Boolean, hasMore: Boolean)
|
upsertBooks: List<SearchBook>,
|
||||||
|
removedBookUrls: List<String>,
|
||||||
|
resultCount: Int,
|
||||||
|
processedSources: Int,
|
||||||
|
totalSources: Int,
|
||||||
|
)
|
||||||
|
suspend fun onSearchFinish(isEmpty: Boolean, hasMore: Boolean)
|
||||||
fun onSearchCancel(exception: Throwable? = null)
|
fun onSearchCancel(exception: Throwable? = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class SearchBookKey(
|
||||||
|
val name: String,
|
||||||
|
val author: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class SearchBookChange(
|
||||||
|
val upsertBooks: List<SearchBook> = emptyList(),
|
||||||
|
val removedBookUrls: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,10 +163,8 @@ class CacheBookService : BaseService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//添加每一章到下载队列
|
//添加章节到下载队列
|
||||||
indices.forEach { index ->
|
cacheBook.addDownloads(indices)
|
||||||
cacheBook.addDownload(index)
|
|
||||||
}
|
|
||||||
|
|
||||||
notificationContent = CacheBook.downloadSummary
|
notificationContent = CacheBook.downloadSummary
|
||||||
upCacheBookNotification()
|
upCacheBookNotification()
|
||||||
|
|||||||
+9
-12
@@ -31,7 +31,6 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -94,10 +93,9 @@ private fun BookCacheManageScreen(
|
|||||||
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
|
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
|
||||||
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) }
|
||||||
var expandedBookUrls by rememberSaveable { mutableStateOf(emptySet<String>()) }
|
|
||||||
val allBooks = state.shelfBooks + state.notShelfBooks
|
val allBooks = state.shelfBooks + state.notShelfBooks
|
||||||
val hasRunningDownload = allBooks.any { it.isDownloading }
|
val hasRunningDownload = allBooks.any { it.isDownloading }
|
||||||
val hasDownloadTarget = allBooks.any { it.chapters.any { chapter -> !chapter.isCached } }
|
val hasDownloadTarget = allBooks.any { it.cachedCount < it.totalCount }
|
||||||
|
|
||||||
AppScaffold(
|
AppScaffold(
|
||||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
@@ -160,9 +158,10 @@ private fun BookCacheManageScreen(
|
|||||||
title = "书架书籍",
|
title = "书架书籍",
|
||||||
emptyText = "没有书架内书籍缓存或下载任务",
|
emptyText = "没有书架内书籍缓存或下载任务",
|
||||||
books = state.shelfBooks,
|
books = state.shelfBooks,
|
||||||
expandedBookUrls = expandedBookUrls,
|
expandedBookUrls = state.expandedBookUrls,
|
||||||
|
chaptersByBookUrl = state.chaptersByBookUrl,
|
||||||
onToggleExpanded = { bookUrl ->
|
onToggleExpanded = { bookUrl ->
|
||||||
expandedBookUrls = expandedBookUrls.toggle(bookUrl)
|
onIntent(BookCacheManageIntent.ToggleBookExpanded(bookUrl))
|
||||||
},
|
},
|
||||||
onIntent = onIntent,
|
onIntent = onIntent,
|
||||||
onDeleteBook = { pendingDeleteBook = it },
|
onDeleteBook = { pendingDeleteBook = it },
|
||||||
@@ -172,9 +171,10 @@ private fun BookCacheManageScreen(
|
|||||||
title = "未在书架",
|
title = "未在书架",
|
||||||
emptyText = "没有未在书架的书籍下载状态",
|
emptyText = "没有未在书架的书籍下载状态",
|
||||||
books = state.notShelfBooks,
|
books = state.notShelfBooks,
|
||||||
expandedBookUrls = expandedBookUrls,
|
expandedBookUrls = state.expandedBookUrls,
|
||||||
|
chaptersByBookUrl = state.chaptersByBookUrl,
|
||||||
onToggleExpanded = { bookUrl ->
|
onToggleExpanded = { bookUrl ->
|
||||||
expandedBookUrls = expandedBookUrls.toggle(bookUrl)
|
onIntent(BookCacheManageIntent.ToggleBookExpanded(bookUrl))
|
||||||
},
|
},
|
||||||
onIntent = onIntent,
|
onIntent = onIntent,
|
||||||
onDeleteBook = { pendingDeleteBook = it },
|
onDeleteBook = { pendingDeleteBook = it },
|
||||||
@@ -212,6 +212,7 @@ private fun LazyListScope.cacheSection(
|
|||||||
emptyText: String,
|
emptyText: String,
|
||||||
books: List<BookCacheBookItem>,
|
books: List<BookCacheBookItem>,
|
||||||
expandedBookUrls: Set<String>,
|
expandedBookUrls: Set<String>,
|
||||||
|
chaptersByBookUrl: Map<String, List<BookCacheChapterItem>>,
|
||||||
onToggleExpanded: (String) -> Unit,
|
onToggleExpanded: (String) -> Unit,
|
||||||
onIntent: (BookCacheManageIntent) -> Unit,
|
onIntent: (BookCacheManageIntent) -> Unit,
|
||||||
onDeleteBook: (BookCacheBookItem) -> Unit,
|
onDeleteBook: (BookCacheBookItem) -> Unit,
|
||||||
@@ -245,7 +246,7 @@ private fun LazyListScope.cacheSection(
|
|||||||
}
|
}
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
items(
|
items(
|
||||||
items = item.chapters,
|
items = chaptersByBookUrl[bookUrl].orEmpty(),
|
||||||
key = { chapter -> "$title-chapter-$bookUrl-${chapter.chapter.url}" }
|
key = { chapter -> "$title-chapter-$bookUrl-${chapter.chapter.url}" }
|
||||||
) { chapter ->
|
) { chapter ->
|
||||||
BookCacheChapterRow(
|
BookCacheChapterRow(
|
||||||
@@ -392,10 +393,6 @@ private fun BookCacheChapterRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Set<String>.toggle(value: String): Set<String> {
|
|
||||||
return if (contains(value)) this - value else this + value
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun chapterStatusText(item: BookCacheChapterItem): String {
|
private fun chapterStatusText(item: BookCacheChapterItem): String {
|
||||||
return when {
|
return when {
|
||||||
item.isDownloading -> "下载中"
|
item.isDownloading -> "下载中"
|
||||||
|
|||||||
+243
-61
@@ -16,6 +16,8 @@ import io.legado.app.help.book.isNotShelf
|
|||||||
import io.legado.app.model.CacheBook
|
import io.legado.app.model.CacheBook
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
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
|
||||||
@@ -23,25 +25,27 @@ 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 kotlin.math.min
|
||||||
|
|
||||||
data class BookCacheManageUiState(
|
data class BookCacheManageUiState(
|
||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
val shelfBooks: List<BookCacheBookItem> = emptyList(),
|
val shelfBooks: List<BookCacheBookItem> = emptyList(),
|
||||||
val notShelfBooks: List<BookCacheBookItem> = emptyList(),
|
val notShelfBooks: List<BookCacheBookItem> = emptyList(),
|
||||||
|
val expandedBookUrls: Set<String> = emptySet(),
|
||||||
|
val chaptersByBookUrl: Map<String, List<BookCacheChapterItem>> = emptyMap(),
|
||||||
val downloadSummary: String = CacheBook.downloadSummary,
|
val downloadSummary: String = CacheBook.downloadSummary,
|
||||||
val version: Long = 0,
|
val version: Long = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class BookCacheBookItem(
|
data class BookCacheBookItem(
|
||||||
val book: Book,
|
val book: Book,
|
||||||
val chapters: List<BookCacheChapterItem>,
|
val totalCount: Int,
|
||||||
val cachedCount: Int,
|
val cachedCount: Int,
|
||||||
val waitingCount: Int,
|
val waitingCount: Int,
|
||||||
val downloadingCount: Int,
|
val downloadingCount: Int,
|
||||||
val errorCount: Int,
|
val errorCount: Int,
|
||||||
val isNotShelf: Boolean,
|
val isNotShelf: Boolean,
|
||||||
) {
|
) {
|
||||||
val totalCount: Int get() = chapters.size
|
|
||||||
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 isDownloading: Boolean get() = waitingCount > 0 || downloadingCount > 0
|
||||||
}
|
}
|
||||||
@@ -59,6 +63,7 @@ sealed interface BookCacheManageIntent {
|
|||||||
data object Refresh : BookCacheManageIntent
|
data object Refresh : BookCacheManageIntent
|
||||||
data object StartAllDownloads : BookCacheManageIntent
|
data object StartAllDownloads : BookCacheManageIntent
|
||||||
data object StopAllDownloads : BookCacheManageIntent
|
data object StopAllDownloads : BookCacheManageIntent
|
||||||
|
data class ToggleBookExpanded(val bookUrl: String) : BookCacheManageIntent
|
||||||
data class DeleteBookCache(val bookUrl: String) : BookCacheManageIntent
|
data class DeleteBookCache(val bookUrl: String) : BookCacheManageIntent
|
||||||
data class DownloadChapter(val bookUrl: String, val chapterIndex: Int) : BookCacheManageIntent
|
data class DownloadChapter(val bookUrl: String, val chapterIndex: Int) : BookCacheManageIntent
|
||||||
data class DeleteChapterCache(val bookUrl: String, val chapterUrl: String) : BookCacheManageIntent
|
data class DeleteChapterCache(val bookUrl: String, val chapterUrl: String) : BookCacheManageIntent
|
||||||
@@ -83,14 +88,16 @@ class BookCacheManageViewModel(
|
|||||||
val effects = _effects.asSharedFlow()
|
val effects = _effects.asSharedFlow()
|
||||||
|
|
||||||
private var observeJob: Job? = null
|
private var observeJob: Job? = null
|
||||||
private val chapterCache = hashMapOf<String, List<BookChapter>>()
|
private var fullReloadJob: Job? = null
|
||||||
|
private val bookReloadJobs = hashMapOf<String, Job>()
|
||||||
|
|
||||||
fun onIntent(intent: BookCacheManageIntent) {
|
fun onIntent(intent: BookCacheManageIntent) {
|
||||||
when (intent) {
|
when (intent) {
|
||||||
BookCacheManageIntent.Initialize -> initialize()
|
BookCacheManageIntent.Initialize -> initialize()
|
||||||
BookCacheManageIntent.Refresh -> reload()
|
BookCacheManageIntent.Refresh -> reloadAll(forceDatabase = true)
|
||||||
BookCacheManageIntent.StartAllDownloads -> startAllDownloads()
|
BookCacheManageIntent.StartAllDownloads -> startAllDownloads()
|
||||||
BookCacheManageIntent.StopAllDownloads -> stopAllDownloads()
|
BookCacheManageIntent.StopAllDownloads -> stopAllDownloads()
|
||||||
|
is BookCacheManageIntent.ToggleBookExpanded -> toggleBookExpanded(intent.bookUrl)
|
||||||
is BookCacheManageIntent.DeleteBookCache -> deleteBookCache(intent.bookUrl)
|
is BookCacheManageIntent.DeleteBookCache -> deleteBookCache(intent.bookUrl)
|
||||||
is BookCacheManageIntent.DownloadChapter -> downloadChapter(
|
is BookCacheManageIntent.DownloadChapter -> downloadChapter(
|
||||||
intent.bookUrl,
|
intent.bookUrl,
|
||||||
@@ -106,23 +113,28 @@ class BookCacheManageViewModel(
|
|||||||
private fun initialize() {
|
private fun initialize() {
|
||||||
if (observeJob != null) return
|
if (observeJob != null) return
|
||||||
observeJob = viewModelScope.launch {
|
observeJob = viewModelScope.launch {
|
||||||
bookDao.flowAll().collect {
|
bookDao.flowAll().collect { books ->
|
||||||
reload(it)
|
reloadAll(books = books, forceDatabase = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
CacheBook.cacheSuccessFlow.collect {
|
CacheBook.cacheSuccessFlow.collect { chapter ->
|
||||||
reload()
|
scheduleBookReload(chapter.bookUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
CacheBook.downloadingIndicesFlow.collect {
|
CacheBook.downloadingIndicesFlow.collect { state ->
|
||||||
reload()
|
scheduleBookReload(state.first)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
CacheBook.downloadErrorFlow.collect {
|
CacheBook.queueChangedFlow.collect { bookUrl ->
|
||||||
reload()
|
scheduleBookReload(bookUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
CacheBook.downloadErrorFlow.collect { state ->
|
||||||
|
scheduleBookReload(state.first)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -130,53 +142,146 @@ class BookCacheManageViewModel(
|
|||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(downloadSummary = summary, version = it.version + 1)
|
it.copy(downloadSummary = summary, version = it.version + 1)
|
||||||
}
|
}
|
||||||
reload()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun reload(books: List<Book>? = null) {
|
private fun reloadAll(
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
books: List<Book>? = null,
|
||||||
val sourceBooks = books ?: bookDao.all
|
forceDatabase: Boolean = false,
|
||||||
val items = sourceBooks
|
) {
|
||||||
.filterNot { it.isLocal || it.isAudio }
|
fullReloadJob?.cancel()
|
||||||
.mapNotNull { book -> buildBookItem(book) }
|
bookReloadJobs.values.forEach { it.cancel() }
|
||||||
.filter { item ->
|
bookReloadJobs.clear()
|
||||||
item.cachedCount > 0 || item.isDownloading || item.errorCount > 0
|
fullReloadJob = viewModelScope.launch {
|
||||||
|
val expandedBookUrls = uiState.value.expandedBookUrls
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
val sourceBooks = if (forceDatabase) bookDao.all else books ?: bookDao.all
|
||||||
|
val items = sortItems(
|
||||||
|
sourceBooks
|
||||||
|
.filterNot { it.isLocal || it.isAudio }
|
||||||
|
.mapNotNull { book -> buildBookItem(book) }
|
||||||
|
.filter { item ->
|
||||||
|
item.cachedCount > 0 || item.isDownloading || item.errorCount > 0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
val booksByUrl = items.associateBy { it.book.bookUrl }
|
||||||
|
val retainedExpandedBookUrls = expandedBookUrls.filterTo(linkedSetOf()) {
|
||||||
|
booksByUrl.containsKey(it)
|
||||||
}
|
}
|
||||||
.sortedWith(compareByDescending<BookCacheBookItem> { it.isDownloading }
|
val chaptersByBookUrl = retainedExpandedBookUrls.associateWith { bookUrl ->
|
||||||
.thenByDescending { it.cachedCount }
|
buildChapterItems(booksByUrl.getValue(bookUrl).book)
|
||||||
.thenBy { it.book.name })
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(
|
|
||||||
isLoading = false,
|
|
||||||
shelfBooks = items.filterNot { item -> item.isNotShelf },
|
|
||||||
notShelfBooks = items.filter { item -> item.isNotShelf },
|
|
||||||
downloadSummary = CacheBook.downloadSummary,
|
|
||||||
version = it.version + 1,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
LoadedCacheState(
|
||||||
|
items = items,
|
||||||
|
expandedBookUrls = retainedExpandedBookUrls,
|
||||||
|
chaptersByBookUrl = chaptersByBookUrl,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
isLoading = false,
|
||||||
|
shelfBooks = result.items.filterNot { item -> item.isNotShelf },
|
||||||
|
notShelfBooks = result.items.filter { item -> item.isNotShelf },
|
||||||
|
expandedBookUrls = result.expandedBookUrls,
|
||||||
|
chaptersByBookUrl = result.chaptersByBookUrl,
|
||||||
|
downloadSummary = CacheBook.downloadSummary,
|
||||||
|
version = state.version + 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleBookReload(bookUrl: String, debounceMillis: Long = 80) {
|
||||||
|
if (bookUrl.isBlank()) return
|
||||||
|
bookReloadJobs.remove(bookUrl)?.cancel()
|
||||||
|
bookReloadJobs[bookUrl] = viewModelScope.launch {
|
||||||
|
if (debounceMillis > 0) {
|
||||||
|
delay(debounceMillis)
|
||||||
|
}
|
||||||
|
reloadBook(bookUrl)
|
||||||
|
if (bookReloadJobs[bookUrl] == currentCoroutineContext()[Job]) {
|
||||||
|
bookReloadJobs.remove(bookUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun reloadBook(bookUrl: String) {
|
||||||
|
val expanded = uiState.value.expandedBookUrls.contains(bookUrl)
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
val book = bookDao.getBook(bookUrl)
|
||||||
|
val item = book
|
||||||
|
?.takeUnless { it.isLocal || it.isAudio }
|
||||||
|
?.let { buildBookItem(it) }
|
||||||
|
?.takeIf {
|
||||||
|
it.cachedCount > 0 || it.isDownloading || it.errorCount > 0
|
||||||
|
}
|
||||||
|
val chapters = if (expanded && item != null) {
|
||||||
|
buildChapterItems(item.book)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
LoadedBookState(item, chapters)
|
||||||
|
}
|
||||||
|
_uiState.update { state ->
|
||||||
|
val combinedBooks = (state.shelfBooks + state.notShelfBooks)
|
||||||
|
.filterNot { it.book.bookUrl == bookUrl }
|
||||||
|
.let { items ->
|
||||||
|
result.item?.let { items + it } ?: items
|
||||||
|
}
|
||||||
|
val sortedBooks = sortItems(combinedBooks)
|
||||||
|
val expandedBookUrls = if (result.item == null) {
|
||||||
|
state.expandedBookUrls - bookUrl
|
||||||
|
} else {
|
||||||
|
state.expandedBookUrls
|
||||||
|
}
|
||||||
|
val chaptersByBookUrl = when {
|
||||||
|
result.item == null -> state.chaptersByBookUrl - bookUrl
|
||||||
|
result.chapters != null -> state.chaptersByBookUrl + (bookUrl to result.chapters)
|
||||||
|
else -> state.chaptersByBookUrl
|
||||||
|
}
|
||||||
|
state.copy(
|
||||||
|
shelfBooks = sortedBooks.filterNot { item -> item.isNotShelf },
|
||||||
|
notShelfBooks = sortedBooks.filter { item -> item.isNotShelf },
|
||||||
|
expandedBookUrls = expandedBookUrls,
|
||||||
|
chaptersByBookUrl = chaptersByBookUrl,
|
||||||
|
downloadSummary = CacheBook.downloadSummary,
|
||||||
|
version = state.version + 1,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildBookItem(book: Book): BookCacheBookItem? {
|
private fun buildBookItem(book: Book): BookCacheBookItem? {
|
||||||
val chapters = chapterCache.getOrPut(book.bookUrl) {
|
|
||||||
bookChapterDao.getChapterList(book.bookUrl)
|
|
||||||
}
|
|
||||||
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 waitingIndices = model?.waitingIndices().orEmpty()
|
||||||
val downloadingIndices = model?.downloadingIndices().orEmpty()
|
val downloadingIndices = model?.downloadingIndices().orEmpty()
|
||||||
val errorIndices = CacheBook.downloadErrorFlow.value
|
val errorIndices = errorIndices(book.bookUrl)
|
||||||
.takeIf { it.first == book.bookUrl }
|
val totalCount = bookChapterDao.getChapterCount(book.bookUrl)
|
||||||
?.second
|
val cachedFileCount = cacheFiles.count { it.endsWith(".nb") }
|
||||||
.orEmpty()
|
val cachedCount = min(cachedFileCount + bookChapterDao.getVolumeCount(book.bookUrl), totalCount)
|
||||||
if (chapters.isEmpty() && cacheFiles.isEmpty() && model == null && !book.isNotShelf) {
|
if (totalCount == 0 && cacheFiles.isEmpty() && model == null && !book.isNotShelf) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val chapterItems = chapters.map { chapter ->
|
return BookCacheBookItem(
|
||||||
|
book = book,
|
||||||
|
totalCount = totalCount,
|
||||||
|
cachedCount = cachedCount,
|
||||||
|
waitingCount = waitingIndices.size,
|
||||||
|
downloadingCount = downloadingIndices.size,
|
||||||
|
errorCount = errorIndices.size,
|
||||||
|
isNotShelf = book.isNotShelf,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildChapterItems(book: Book): List<BookCacheChapterItem> {
|
||||||
|
val chapters = bookChapterDao.getChapterList(book.bookUrl)
|
||||||
|
val cacheFiles = BookHelp.getChapterFiles(book)
|
||||||
|
val model = CacheBook.cacheBookMap[book.bookUrl]
|
||||||
|
val waitingIndices = model?.waitingIndices().orEmpty()
|
||||||
|
val downloadingIndices = model?.downloadingIndices().orEmpty()
|
||||||
|
val errorIndices = errorIndices(book.bookUrl)
|
||||||
|
return chapters.map { chapter ->
|
||||||
BookCacheChapterItem(
|
BookCacheChapterItem(
|
||||||
chapter = chapter,
|
chapter = chapter,
|
||||||
isCached = cacheFiles.contains(chapter.getFileName()) || chapter.isVolume,
|
isCached = cacheFiles.contains(chapter.getFileName()) || chapter.isVolume,
|
||||||
@@ -185,29 +290,68 @@ class BookCacheManageViewModel(
|
|||||||
isError = errorIndices.contains(chapter.index),
|
isError = errorIndices.contains(chapter.index),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return BookCacheBookItem(
|
}
|
||||||
book = book,
|
|
||||||
chapters = chapterItems,
|
private fun errorIndices(bookUrl: String): Set<Int> {
|
||||||
cachedCount = chapterItems.count { it.isCached },
|
return CacheBook.errorIndices(bookUrl)
|
||||||
waitingCount = waitingIndices.size,
|
}
|
||||||
downloadingCount = downloadingIndices.size,
|
|
||||||
errorCount = errorIndices.size,
|
private fun toggleBookExpanded(bookUrl: String) {
|
||||||
isNotShelf = book.isNotShelf,
|
val shouldExpand = !_uiState.value.expandedBookUrls.contains(bookUrl)
|
||||||
)
|
_uiState.update { state ->
|
||||||
|
if (shouldExpand) {
|
||||||
|
state.copy(
|
||||||
|
expandedBookUrls = state.expandedBookUrls + bookUrl,
|
||||||
|
version = state.version + 1,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
state.copy(
|
||||||
|
expandedBookUrls = state.expandedBookUrls - bookUrl,
|
||||||
|
chaptersByBookUrl = state.chaptersByBookUrl - bookUrl,
|
||||||
|
version = state.version + 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (shouldExpand) {
|
||||||
|
loadBookChapters(bookUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadBookChapters(bookUrl: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val chapters = withContext(Dispatchers.IO) {
|
||||||
|
val book = findBook(bookUrl) ?: bookDao.getBook(bookUrl) ?: return@withContext null
|
||||||
|
buildChapterItems(book)
|
||||||
|
} ?: return@launch
|
||||||
|
_uiState.update { state ->
|
||||||
|
if (!state.expandedBookUrls.contains(bookUrl)) {
|
||||||
|
state
|
||||||
|
} else {
|
||||||
|
state.copy(
|
||||||
|
chaptersByBookUrl = state.chaptersByBookUrl + (bookUrl to chapters),
|
||||||
|
version = state.version + 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findBook(bookUrl: String): Book? {
|
||||||
|
return (uiState.value.shelfBooks + uiState.value.notShelfBooks)
|
||||||
|
.firstOrNull { it.book.bookUrl == bookUrl }
|
||||||
|
?.book
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopAllDownloads() {
|
private fun stopAllDownloads() {
|
||||||
CacheBook.stop(context)
|
CacheBook.stop(context)
|
||||||
reload()
|
reloadAll(forceDatabase = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 ->
|
items.sumOf { item ->
|
||||||
val chapterIndices = item.chapters
|
val chapterIndices = downloadableChapterIndices(item.book)
|
||||||
.filterNot { it.isCached || it.isWaiting || it.isDownloading }
|
|
||||||
.map { it.chapter.index }
|
|
||||||
cacheBookChaptersUseCase.execute(item.book.bookUrl, chapterIndices)
|
cacheBookChaptersUseCase.execute(item.book.bookUrl, chapterIndices)
|
||||||
}
|
}
|
||||||
}.onSuccess { count ->
|
}.onSuccess { count ->
|
||||||
@@ -219,10 +363,27 @@ class BookCacheManageViewModel(
|
|||||||
}.onError {
|
}.onError {
|
||||||
_effects.tryEmit(BookCacheManageEffect.ShowMessage("加入缓存队列失败\n${it.localizedMessage}"))
|
_effects.tryEmit(BookCacheManageEffect.ShowMessage("加入缓存队列失败\n${it.localizedMessage}"))
|
||||||
}.onFinally {
|
}.onFinally {
|
||||||
reload()
|
reloadAll(forceDatabase = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun downloadableChapterIndices(book: Book): List<Int> {
|
||||||
|
val cacheFiles = BookHelp.getChapterFiles(book)
|
||||||
|
val model = CacheBook.cacheBookMap[book.bookUrl]
|
||||||
|
val waitingIndices = model?.waitingIndices().orEmpty()
|
||||||
|
val downloadingIndices = model?.downloadingIndices().orEmpty()
|
||||||
|
return bookChapterDao.getChapterList(book.bookUrl)
|
||||||
|
.asSequence()
|
||||||
|
.filterNot { chapter ->
|
||||||
|
chapter.isVolume ||
|
||||||
|
cacheFiles.contains(chapter.getFileName()) ||
|
||||||
|
waitingIndices.contains(chapter.index) ||
|
||||||
|
downloadingIndices.contains(chapter.index)
|
||||||
|
}
|
||||||
|
.map { it.index }
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
private fun deleteBookCache(bookUrl: String) {
|
private fun deleteBookCache(bookUrl: String) {
|
||||||
CacheBook.remove(context, bookUrl)
|
CacheBook.remove(context, bookUrl)
|
||||||
execute {
|
execute {
|
||||||
@@ -232,7 +393,7 @@ class BookCacheManageViewModel(
|
|||||||
}.onError {
|
}.onError {
|
||||||
_effects.tryEmit(BookCacheManageEffect.ShowMessage("删除缓存失败\n${it.localizedMessage}"))
|
_effects.tryEmit(BookCacheManageEffect.ShowMessage("删除缓存失败\n${it.localizedMessage}"))
|
||||||
}.onFinally {
|
}.onFinally {
|
||||||
reload()
|
scheduleBookReload(bookUrl, debounceMillis = 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,13 +405,17 @@ class BookCacheManageViewModel(
|
|||||||
}.onError {
|
}.onError {
|
||||||
_effects.tryEmit(BookCacheManageEffect.ShowMessage("章节缓存失败\n${it.localizedMessage}"))
|
_effects.tryEmit(BookCacheManageEffect.ShowMessage("章节缓存失败\n${it.localizedMessage}"))
|
||||||
}.onFinally {
|
}.onFinally {
|
||||||
reload()
|
scheduleBookReload(bookUrl, debounceMillis = 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deleteChapterCache(bookUrl: String, chapterUrl: String) {
|
private fun deleteChapterCache(bookUrl: String, chapterUrl: String) {
|
||||||
val book = bookDao.getBook(bookUrl) ?: return
|
val book = findBook(bookUrl) ?: bookDao.getBook(bookUrl) ?: return
|
||||||
val chapter = chapterCache[bookUrl]?.firstOrNull { it.url == chapterUrl } ?: return
|
val chapter = uiState.value.chaptersByBookUrl[bookUrl]
|
||||||
|
?.firstOrNull { it.chapter.url == chapterUrl }
|
||||||
|
?.chapter
|
||||||
|
?: bookChapterDao.getChapterList(bookUrl).firstOrNull { it.url == chapterUrl }
|
||||||
|
?: return
|
||||||
execute {
|
execute {
|
||||||
BookHelp.delContent(book, chapter)
|
BookHelp.delContent(book, chapter)
|
||||||
}.onSuccess {
|
}.onSuccess {
|
||||||
@@ -258,7 +423,24 @@ class BookCacheManageViewModel(
|
|||||||
}.onError {
|
}.onError {
|
||||||
_effects.tryEmit(BookCacheManageEffect.ShowMessage("删除章节缓存失败\n${it.localizedMessage}"))
|
_effects.tryEmit(BookCacheManageEffect.ShowMessage("删除章节缓存失败\n${it.localizedMessage}"))
|
||||||
}.onFinally {
|
}.onFinally {
|
||||||
reload()
|
scheduleBookReload(bookUrl, debounceMillis = 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun sortItems(items: List<BookCacheBookItem>): List<BookCacheBookItem> {
|
||||||
|
return items.sortedWith(compareByDescending<BookCacheBookItem> { it.isDownloading }
|
||||||
|
.thenByDescending { it.cachedCount }
|
||||||
|
.thenBy { it.book.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class LoadedCacheState(
|
||||||
|
val items: List<BookCacheBookItem>,
|
||||||
|
val expandedBookUrls: Set<String>,
|
||||||
|
val chaptersByBookUrl: Map<String, List<BookCacheChapterItem>>,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class LoadedBookState(
|
||||||
|
val item: BookCacheBookItem?,
|
||||||
|
val chapters: List<BookCacheChapterItem>?,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -684,7 +684,7 @@ private fun BookshelfManageScreen(
|
|||||||
) {
|
) {
|
||||||
items(filteredBooks, key = { it.bookUrl }) { book ->
|
items(filteredBooks, key = { it.bookUrl }) { book ->
|
||||||
val cacheCount = remember(renderVersion, book.bookUrl) {
|
val cacheCount = remember(renderVersion, book.bookUrl) {
|
||||||
viewModel.getCacheChapters(book.bookUrl)?.size ?: 0
|
viewModel.getCacheCount(book.bookUrl) ?: 0
|
||||||
}
|
}
|
||||||
val isDownloading = remember(renderVersion, book.bookUrl) {
|
val isDownloading = remember(renderVersion, book.bookUrl) {
|
||||||
viewModel.isBookDownloading(book.bookUrl)
|
viewModel.isBookDownloading(book.bookUrl)
|
||||||
@@ -1512,7 +1512,7 @@ private fun BatchChangePreviewRow(
|
|||||||
chapterCount = if (item.status == BatchChangeSourcePreviewStatus.Skipped) {
|
chapterCount = if (item.status == BatchChangeSourcePreviewStatus.Skipped) {
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
candidate?.chapters?.size
|
candidate?.chapterCount
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
candidate?.book?.let { onOpenBook(it, false) }
|
candidate?.book?.let { onOpenBook(it, false) }
|
||||||
@@ -1624,7 +1624,7 @@ private fun OtherSourceOptionsSheet(
|
|||||||
val candidate = currentItem.candidates[index]
|
val candidate = currentItem.candidates[index]
|
||||||
SelectionItemCard(
|
SelectionItemCard(
|
||||||
title = candidate.source.bookSourceName,
|
title = candidate.source.bookSourceName,
|
||||||
subtitle = "${candidate.book.name} · ${candidate.chapters.size}章",
|
subtitle = "${candidate.book.name} · ${candidate.chapterCount}章",
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
AppText(
|
AppText(
|
||||||
text = candidate.book.getRealAuthor(),
|
text = candidate.book.getRealAuthor(),
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import io.legado.app.ui.config.otherConfig.OtherConfig
|
|||||||
import io.legado.app.ui.main.bookshelf.toLightBook
|
import io.legado.app.ui.main.bookshelf.toLightBook
|
||||||
import io.legado.app.utils.cnCompare
|
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.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.ensureActive
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
@@ -45,6 +46,8 @@ import kotlinx.coroutines.flow.map
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
data class BookshelfManageScreenExportConfig(
|
data class BookshelfManageScreenExportConfig(
|
||||||
val exportUseReplace: Boolean = true,
|
val exportUseReplace: Boolean = true,
|
||||||
@@ -158,9 +161,11 @@ class BookshelfManageScreenViewModel(
|
|||||||
private val _effects = MutableSharedFlow<BookshelfManageScreenEffect>(extraBufferCapacity = 32)
|
private val _effects = MutableSharedFlow<BookshelfManageScreenEffect>(extraBufferCapacity = 32)
|
||||||
val effects = _effects.asSharedFlow()
|
val effects = _effects.asSharedFlow()
|
||||||
|
|
||||||
private val cacheChapters = hashMapOf<String, HashSet<String>>()
|
private val cacheCounts = ConcurrentHashMap<String, Int>()
|
||||||
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 val cacheCountJobs = ConcurrentHashMap<String, Job>()
|
||||||
private var observersStarted = false
|
private var observersStarted = false
|
||||||
|
|
||||||
fun dispatch(intent: BookshelfManageScreenIntent) {
|
fun dispatch(intent: BookshelfManageScreenIntent) {
|
||||||
@@ -285,9 +290,7 @@ class BookshelfManageScreenViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCacheChapters(bookUrl: String): Set<String>? = cacheChapters[bookUrl]
|
fun getCacheCount(bookUrl: String): Int? = cacheCounts[bookUrl]
|
||||||
|
|
||||||
fun getAllCacheChapters(): Map<String, Set<String>> = cacheChapters
|
|
||||||
|
|
||||||
fun isBookDownloading(bookUrl: String): Boolean {
|
fun isBookDownloading(bookUrl: String): Boolean {
|
||||||
return CacheBook.cacheBookMap[bookUrl]?.isStop() == false
|
return CacheBook.cacheBookMap[bookUrl]?.isStop() == false
|
||||||
@@ -354,7 +357,7 @@ class BookshelfManageScreenViewModel(
|
|||||||
bookSortOrder = bookshelfManageScreenConfig.bookshelfSortOrder
|
bookSortOrder = bookshelfManageScreenConfig.bookshelfSortOrder
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
loadCacheFiles(books)
|
loadCacheCounts(books)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -427,22 +430,20 @@ class BookshelfManageScreenViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadCacheFiles(books: List<Book>) {
|
private fun loadCacheCounts(books: List<Book>) {
|
||||||
execute {
|
cacheLoadJob?.cancel()
|
||||||
|
cacheCountJobs.values.forEach { it.cancel() }
|
||||||
|
cacheCountJobs.clear()
|
||||||
|
cacheLoadJob = viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val visibleBookUrls = books.mapTo(hashSetOf()) { it.bookUrl }
|
||||||
|
cacheCounts.keys.toList().forEach { bookUrl ->
|
||||||
|
if (!visibleBookUrls.contains(bookUrl)) {
|
||||||
|
cacheCounts.remove(bookUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
books.forEach { book ->
|
books.forEach { book ->
|
||||||
if (!book.isLocal && !cacheChapters.contains(book.bookUrl)) {
|
if (!book.isLocal && !cacheCounts.containsKey(book.bookUrl)) {
|
||||||
val chapterCaches = hashSetOf<String>()
|
cacheCounts[book.bookUrl] = calculateCacheCount(book)
|
||||||
val cacheNames = BookHelp.getChapterFiles(book)
|
|
||||||
if (cacheNames.isNotEmpty()) {
|
|
||||||
bookChapterDao.getChapterList(book.bookUrl).also {
|
|
||||||
book.totalChapterNum = it.size
|
|
||||||
}.forEach { chapter ->
|
|
||||||
if (cacheNames.contains(chapter.getFileName()) || chapter.isVolume) {
|
|
||||||
chapterCaches.add(chapter.url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cacheChapters[book.bookUrl] = chapterCaches
|
|
||||||
emitBookChanged(book.bookUrl)
|
emitBookChanged(book.bookUrl)
|
||||||
}
|
}
|
||||||
ensureActive()
|
ensureActive()
|
||||||
@@ -452,9 +453,28 @@ class BookshelfManageScreenViewModel(
|
|||||||
|
|
||||||
private fun onChapterCached(chapter: BookChapter) {
|
private fun onChapterCached(chapter: BookChapter) {
|
||||||
val bookUrl = chapter.bookUrl
|
val bookUrl = chapter.bookUrl
|
||||||
val chapterSet = cacheChapters.getOrPut(bookUrl) { hashSetOf() }
|
scheduleCacheCountRefresh(bookUrl)
|
||||||
chapterSet.add(chapter.url)
|
}
|
||||||
emitBookChanged(bookUrl)
|
|
||||||
|
private fun scheduleCacheCountRefresh(bookUrl: String) {
|
||||||
|
cacheCountJobs.remove(bookUrl)?.cancel()
|
||||||
|
cacheCountJobs[bookUrl] = viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val book = bookDao.getBook(bookUrl) ?: return@launch
|
||||||
|
if (!uiState.value.books.any { it.bookUrl == bookUrl }) {
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
cacheCounts[bookUrl] = calculateCacheCount(book)
|
||||||
|
emitBookChanged(bookUrl)
|
||||||
|
cacheCountJobs.remove(bookUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateCacheCount(book: Book): Int {
|
||||||
|
val cacheNames = BookHelp.getChapterFiles(book)
|
||||||
|
if (cacheNames.isEmpty()) return 0
|
||||||
|
val totalCount = bookChapterDao.getChapterCount(book.bookUrl)
|
||||||
|
val cachedFileCount = cacheNames.count { it.endsWith(".nb") }
|
||||||
|
return min(cachedFileCount + bookChapterDao.getVolumeCount(book.bookUrl), totalCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startDownloadForVisibleBooks(books: List<Book>, downloadAllChapters: Boolean) {
|
private fun startDownloadForVisibleBooks(books: List<Book>, downloadAllChapters: Boolean) {
|
||||||
@@ -500,7 +520,7 @@ class BookshelfManageScreenViewModel(
|
|||||||
deleteBooksUseCase.execute(bookUrls, deleteOriginal)
|
deleteBooksUseCase.execute(bookUrls, deleteOriginal)
|
||||||
}.onSuccess { deletedBookUrls ->
|
}.onSuccess { deletedBookUrls ->
|
||||||
_uiState.update { it.copy(deleteBookOriginal = deleteOriginal) }
|
_uiState.update { it.copy(deleteBookOriginal = deleteOriginal) }
|
||||||
deletedBookUrls.forEach { cacheChapters.remove(it) }
|
deletedBookUrls.forEach { cacheCounts.remove(it) }
|
||||||
_effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("删除成功"))
|
_effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("删除成功"))
|
||||||
}.onError {
|
}.onError {
|
||||||
_effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("删除失败\n${it.localizedMessage}"))
|
_effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("删除失败\n${it.localizedMessage}"))
|
||||||
@@ -513,7 +533,7 @@ class BookshelfManageScreenViewModel(
|
|||||||
clearBookCacheUseCase.execute(bookUrls)
|
clearBookCacheUseCase.execute(bookUrls)
|
||||||
}.onSuccess { clearedBookUrls ->
|
}.onSuccess { clearedBookUrls ->
|
||||||
clearedBookUrls.forEach { bookUrl ->
|
clearedBookUrls.forEach { bookUrl ->
|
||||||
cacheChapters[bookUrl] = hashSetOf()
|
cacheCounts[bookUrl] = 0
|
||||||
emitBookChanged(bookUrl)
|
emitBookChanged(bookUrl)
|
||||||
}
|
}
|
||||||
_effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("缓存已清理"))
|
_effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("缓存已清理"))
|
||||||
@@ -578,8 +598,8 @@ class BookshelfManageScreenViewModel(
|
|||||||
changeBookSourceUseCase.changeTo(oldBook, book, chapters, options)
|
changeBookSourceUseCase.changeTo(oldBook, book, chapters, options)
|
||||||
}.onSuccess { result ->
|
}.onSuccess { result ->
|
||||||
result ?: return@onSuccess
|
result ?: return@onSuccess
|
||||||
cacheChapters.remove(result.oldBookUrl)
|
cacheCounts.remove(result.oldBookUrl)
|
||||||
cacheChapters[result.book.bookUrl] = hashSetOf()
|
cacheCounts[result.book.bookUrl] = 0
|
||||||
emitBookChanged(result.book.bookUrl)
|
emitBookChanged(result.book.bookUrl)
|
||||||
_effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("换源完成"))
|
_effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("换源完成"))
|
||||||
}.onError {
|
}.onError {
|
||||||
@@ -665,15 +685,19 @@ class BookshelfManageScreenViewModel(
|
|||||||
val candidate = item.selectedCandidate ?: return
|
val candidate = item.selectedCandidate ?: return
|
||||||
execute {
|
execute {
|
||||||
val oldBook = bookDao.getBook(oldBookUrl) ?: item.oldBook
|
val oldBook = bookDao.getBook(oldBookUrl) ?: item.oldBook
|
||||||
|
val chapters = changeBookSourceUseCase.loadCandidateChapters(
|
||||||
|
candidate.source,
|
||||||
|
candidate.book
|
||||||
|
) ?: error("获取目录失败")
|
||||||
changeBookSourceUseCase.changeTo(
|
changeBookSourceUseCase.changeTo(
|
||||||
oldBook = oldBook,
|
oldBook = oldBook,
|
||||||
newBook = candidate.book,
|
newBook = candidate.book,
|
||||||
chapters = candidate.chapters,
|
chapters = chapters,
|
||||||
options = uiState.value.batchChangeOptions,
|
options = uiState.value.batchChangeOptions,
|
||||||
)
|
)
|
||||||
}.onSuccess { result ->
|
}.onSuccess { result ->
|
||||||
cacheChapters.remove(result.oldBookUrl)
|
cacheCounts.remove(result.oldBookUrl)
|
||||||
cacheChapters[result.book.bookUrl] = hashSetOf()
|
cacheCounts[result.book.bookUrl] = 0
|
||||||
removePreviewItem(oldBookUrl)
|
removePreviewItem(oldBookUrl)
|
||||||
emitBookChanged(result.book.bookUrl)
|
emitBookChanged(result.book.bookUrl)
|
||||||
_effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("迁移完成"))
|
_effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("迁移完成"))
|
||||||
@@ -726,8 +750,15 @@ class BookshelfManageScreenViewModel(
|
|||||||
state.copy(
|
state.copy(
|
||||||
batchChangePreviewItems = state.batchChangePreviewItems.map { item ->
|
batchChangePreviewItems = state.batchChangePreviewItems.map { item ->
|
||||||
if (item.oldBook.bookUrl == oldBookUrl) {
|
if (item.oldBook.bookUrl == oldBookUrl) {
|
||||||
|
book.totalChapterNum = chapters.size
|
||||||
item.copy(
|
item.copy(
|
||||||
candidates = listOf(BatchChangeSourceCandidate(source, book, chapters)) +
|
candidates = listOf(
|
||||||
|
BatchChangeSourceCandidate(
|
||||||
|
source = source,
|
||||||
|
book = book,
|
||||||
|
chapterCount = chapters.size
|
||||||
|
)
|
||||||
|
) +
|
||||||
item.candidates,
|
item.candidates,
|
||||||
selectedCandidateIndex = 0,
|
selectedCandidateIndex = 0,
|
||||||
status = BatchChangeSourcePreviewStatus.Matched
|
status = BatchChangeSourcePreviewStatus.Matched
|
||||||
@@ -746,12 +777,16 @@ class BookshelfManageScreenViewModel(
|
|||||||
} ?: return
|
} ?: return
|
||||||
val candidate = item.selectedCandidate ?: return
|
val candidate = item.selectedCandidate ?: return
|
||||||
execute {
|
execute {
|
||||||
|
val chapters = changeBookSourceUseCase.loadCandidateChapters(
|
||||||
|
candidate.source,
|
||||||
|
candidate.book
|
||||||
|
) ?: error("获取目录失败")
|
||||||
candidate.book.removeType(BookType.notShelf)
|
candidate.book.removeType(BookType.notShelf)
|
||||||
if (candidate.book.order == 0) {
|
if (candidate.book.order == 0) {
|
||||||
candidate.book.order = bookDao.minOrder - 1
|
candidate.book.order = bookDao.minOrder - 1
|
||||||
}
|
}
|
||||||
bookDao.insert(candidate.book)
|
bookDao.insert(candidate.book)
|
||||||
bookChapterDao.insert(*candidate.chapters.toTypedArray())
|
bookChapterDao.insert(*chapters.toTypedArray())
|
||||||
candidate.book
|
candidate.book
|
||||||
}.onSuccess {
|
}.onSuccess {
|
||||||
_effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("已添加到书架"))
|
_effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("已添加到书架"))
|
||||||
@@ -795,15 +830,19 @@ class BookshelfManageScreenViewModel(
|
|||||||
}
|
}
|
||||||
val candidate = item.selectedCandidate ?: return@forEachIndexed
|
val candidate = item.selectedCandidate ?: return@forEachIndexed
|
||||||
val oldBook = bookDao.getBook(item.oldBook.bookUrl) ?: item.oldBook
|
val oldBook = bookDao.getBook(item.oldBook.bookUrl) ?: item.oldBook
|
||||||
|
val chapters = changeBookSourceUseCase.loadCandidateChapters(
|
||||||
|
candidate.source,
|
||||||
|
candidate.book
|
||||||
|
) ?: return@forEachIndexed
|
||||||
changeBookSourceUseCase.changeTo(
|
changeBookSourceUseCase.changeTo(
|
||||||
oldBook = oldBook,
|
oldBook = oldBook,
|
||||||
newBook = candidate.book,
|
newBook = candidate.book,
|
||||||
chapters = candidate.chapters,
|
chapters = chapters,
|
||||||
options = uiState.value.batchChangeOptions,
|
options = uiState.value.batchChangeOptions,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.onSuccess {
|
}.onSuccess {
|
||||||
cacheChapters.clear()
|
cacheCounts.clear()
|
||||||
_uiState.update { it.copy(batchChangePreviewItems = emptyList()) }
|
_uiState.update { it.copy(batchChangePreviewItems = emptyList()) }
|
||||||
_effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("批量迁移完成"))
|
_effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("批量迁移完成"))
|
||||||
}.onError {
|
}.onError {
|
||||||
@@ -824,7 +863,7 @@ class BookshelfManageScreenViewModel(
|
|||||||
clearBookCacheUseCase.execute(book.bookUrl)
|
clearBookCacheUseCase.execute(book.bookUrl)
|
||||||
}.onSuccess { bookUrl ->
|
}.onSuccess { bookUrl ->
|
||||||
bookUrl ?: return@onSuccess
|
bookUrl ?: return@onSuccess
|
||||||
cacheChapters[bookUrl] = hashSetOf()
|
cacheCounts[bookUrl] = 0
|
||||||
emitBookChanged(bookUrl)
|
emitBookChanged(bookUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ class SearchViewModel(
|
|||||||
private val bookshelfKeys = MutableStateFlow<Set<BookShelfKey>>(emptySet())
|
private val bookshelfKeys = MutableStateFlow<Set<BookShelfKey>>(emptySet())
|
||||||
private val searchScope = SearchScope(AppConfig.searchScope)
|
private val searchScope = SearchScope(AppConfig.searchScope)
|
||||||
private val searchSession = repository.createSearchSession { searchScope }
|
private val searchSession = repository.createSearchSession { searchScope }
|
||||||
|
private val searchResultBooks = LinkedHashMap<String, SearchBook>()
|
||||||
|
|
||||||
private var currentSearchId = 0L
|
private var currentSearchId = 0L
|
||||||
|
|
||||||
@@ -231,10 +232,15 @@ class SearchViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is SearchSessionEvent.Progress -> {
|
is SearchSessionEvent.Progress -> {
|
||||||
val shelf = bookshelfKeys.value
|
event.removedBookUrls.forEach { searchResultBooks.remove(it) }
|
||||||
|
event.upsertBooks.forEach { book ->
|
||||||
|
searchResultBooks[book.bookUrl] = book
|
||||||
|
}
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
results = event.books.toSearchResultItems(shelf),
|
results = buildSearchResultItems(
|
||||||
|
shelf = bookshelfKeys.value,
|
||||||
|
),
|
||||||
processedSources = event.processedSources,
|
processedSources = event.processedSources,
|
||||||
totalSources = event.totalSources,
|
totalSources = event.totalSources,
|
||||||
)
|
)
|
||||||
@@ -292,6 +298,7 @@ class SearchViewModel(
|
|||||||
updateQuery(keyword, showSuggestions = false)
|
updateQuery(keyword, showSuggestions = false)
|
||||||
|
|
||||||
currentSearchId = System.currentTimeMillis()
|
currentSearchId = System.currentTimeMillis()
|
||||||
|
searchResultBooks.clear()
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
committedQuery = keyword,
|
committedQuery = keyword,
|
||||||
@@ -418,6 +425,12 @@ class SearchViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun buildSearchResultItems(
|
||||||
|
shelf: Set<BookShelfKey>,
|
||||||
|
): List<SearchResultItemUi> {
|
||||||
|
return searchResultBooks.values.toList().toSearchResultItems(shelf)
|
||||||
|
}
|
||||||
|
|
||||||
private fun List<SearchResultItemUi>.withShelfState(
|
private fun List<SearchResultItemUi>.withShelfState(
|
||||||
shelf: Set<BookShelfKey>
|
shelf: Set<BookShelfKey>
|
||||||
): List<SearchResultItemUi> {
|
): List<SearchResultItemUi> {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) :
|
|||||||
|
|
||||||
private val normalClosure = NanoWSD.WebSocketFrame.CloseCode.NormalClosure
|
private val normalClosure = NanoWSD.WebSocketFrame.CloseCode.NormalClosure
|
||||||
private val searchModel = SearchModel(this, this)
|
private val searchModel = SearchModel(this, this)
|
||||||
|
private val sentBookUrls = linkedSetOf<String>()
|
||||||
|
|
||||||
private val SEARCH_FINISH = "Search finish"
|
private val SEARCH_FINISH = "Search finish"
|
||||||
|
|
||||||
@@ -66,6 +67,7 @@ class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) :
|
|||||||
close(normalClosure, SEARCH_FINISH, false)
|
close(normalClosure, SEARCH_FINISH, false)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
sentBookUrls.clear()
|
||||||
searchModel.search(System.currentTimeMillis(), key)
|
searchModel.search(System.currentTimeMillis(), key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,19 +84,24 @@ class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) :
|
|||||||
|
|
||||||
override fun getSearchScope(): SearchScope = SearchScope(AppConfig.searchScope)
|
override fun getSearchScope(): SearchScope = SearchScope(AppConfig.searchScope)
|
||||||
|
|
||||||
override fun onSearchStart() {
|
override suspend fun onSearchStart() {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSearchSuccess(
|
override suspend fun onSearchSuccess(
|
||||||
searchBooks: List<SearchBook>,
|
upsertBooks: List<SearchBook>,
|
||||||
|
removedBookUrls: List<String>,
|
||||||
|
resultCount: Int,
|
||||||
processedSources: Int,
|
processedSources: Int,
|
||||||
totalSources: Int
|
totalSources: Int
|
||||||
) {
|
) {
|
||||||
send(GSON.toJson(searchBooks))
|
val newBooks = upsertBooks.filter { sentBookUrls.add(it.bookUrl) }
|
||||||
|
if (newBooks.isNotEmpty()) {
|
||||||
|
send(GSON.toJson(newBooks))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSearchFinish(isEmpty: Boolean, hasMore: Boolean) = close(normalClosure, SEARCH_FINISH, false)
|
override suspend fun onSearchFinish(isEmpty: Boolean, hasMore: Boolean) = close(normalClosure, SEARCH_FINISH, false)
|
||||||
|
|
||||||
override fun onSearchCancel(exception: Throwable?) = close(normalClosure, exception?.toString() ?: SEARCH_FINISH, false)
|
override fun onSearchCancel(exception: Throwable?) = close(normalClosure, exception?.toString() ?: SEARCH_FINISH, false)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user