Perform download cache renewal async

Don't block on cache renewals, but notify library on updates so that the badges show up when ready.

We skip the cache when checking if a chapter is downloaded for the reader assuming that it's a
relatively low cost to check for a single chapter.

(Probably) fixes #8254 / fixes #7847
This commit is contained in:
arkon 2022-10-21 15:00:41 -04:00
parent 93925a7286
commit 7e40680af0
6 changed files with 99 additions and 52 deletions

View file

@ -6,12 +6,21 @@ import com.hippo.unifile.UniFile
import eu.kanade.domain.download.service.DownloadPreferences
import eu.kanade.domain.manga.model.Manga
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.util.lang.launchIO
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withTimeout
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.concurrent.TimeUnit
@ -26,9 +35,15 @@ class DownloadCache(
private val context: Context,
private val provider: DownloadProvider = Injekt.get(),
private val sourceManager: SourceManager = Injekt.get(),
private val extensionManager: ExtensionManager = Injekt.get(),
private val downloadPreferences: DownloadPreferences = Injekt.get(),
) {
// This is just a mechanism of notifying consumers of updates to the cache, the value itself
// is meaningless.
private val _state: MutableStateFlow<Long> = MutableStateFlow(0L)
val changes = _state.asStateFlow()
private val scope = CoroutineScope(Dispatchers.IO)
/**
@ -41,6 +56,7 @@ class DownloadCache(
* The last time the cache was refreshed.
*/
private var lastRenew = 0L
private var renewalJob: Job? = null
private var rootDownloadsDir = RootDirectory(getDirectoryFromPreference())
@ -134,6 +150,8 @@ class DownloadCache(
// Save the chapter directory
mangaDir.chapterDirs += chapterDirName
notifyChanges()
}
/**
@ -151,6 +169,8 @@ class DownloadCache(
mangaDir.chapterDirs -= it
}
}
notifyChanges()
}
/**
@ -170,6 +190,8 @@ class DownloadCache(
}
}
}
notifyChanges()
}
/**
@ -184,6 +206,8 @@ class DownloadCache(
if (mangaDirName in sourceDir.mangaDirs) {
sourceDir.mangaDirs -= mangaDirName
}
notifyChanges()
}
@Synchronized
@ -193,6 +217,8 @@ class DownloadCache(
sourceDir.delete()
rootDownloadsDir.sourceDirs -= source.id
}
notifyChanges()
}
/**
@ -206,76 +232,83 @@ class DownloadCache(
/**
* Renews the downloads cache.
*/
@Synchronized
private fun renewCache() {
if (lastRenew + renewInterval >= System.currentTimeMillis()) {
// Avoid renewing cache if in the process nor too often
if (lastRenew + renewInterval >= System.currentTimeMillis() || renewalJob?.isActive == true) {
return
}
val sources = sourceManager.getOnlineSources() + sourceManager.getStubSources()
renewalJob = scope.launchIO {
var sources = getSources()
// Ensure we try again later if no sources have been loaded
if (sources.isEmpty()) {
return
}
// Try to wait until extensions and sources have loaded
withTimeout(30000L) {
while (!extensionManager.isInitialized) {
delay(2000L)
}
val sourceDirs = rootDownloadsDir.dir.listFiles()
.orEmpty()
.associate { it.name to SourceDirectory(it) }
.mapNotNullKeys { entry ->
sources.find {
provider.getSourceDirName(it).equals(entry.key, ignoreCase = true)
}?.id
while (sources.isEmpty()) {
delay(2000L)
sources = getSources()
}
}
rootDownloadsDir.sourceDirs = sourceDirs
val sourceDirs = rootDownloadsDir.dir.listFiles().orEmpty()
.associate { it.name to SourceDirectory(it) }
.mapNotNullKeys { entry ->
sources.find {
provider.getSourceDirName(it).equals(entry.key, ignoreCase = true)
}?.id
}
sourceDirs.values.forEach { sourceDir ->
val mangaDirs = sourceDir.dir.listFiles()
.orEmpty()
.associateNotNullKeys { it.name to MangaDirectory(it) }
rootDownloadsDir.sourceDirs = sourceDirs
sourceDir.mangaDirs = mangaDirs
sourceDirs.values
.map { sourceDir ->
async {
val mangaDirs = sourceDir.dir.listFiles().orEmpty()
.filterNot { it.name.isNullOrBlank() }
.associate { it.name!! to MangaDirectory(it) }
.toMutableMap()
mangaDirs.values.forEach { mangaDir ->
val chapterDirs = mangaDir.dir.listFiles()
.orEmpty()
.mapNotNull { chapterDir ->
chapterDir.name
?.replace(".cbz", "")
?.takeUnless { it.endsWith(Downloader.TMP_DIR_SUFFIX) }
sourceDir.mangaDirs = mangaDirs
mangaDirs.values.forEach { mangaDir ->
val chapterDirs = mangaDir.dir.listFiles().orEmpty()
.mapNotNull { chapterDir ->
chapterDir.name
?.replace(".cbz", "")
?.takeUnless { it.endsWith(Downloader.TMP_DIR_SUFFIX) }
}
.toMutableSet()
mangaDir.chapterDirs = chapterDirs
}
}
.toHashSet()
}
.awaitAll()
mangaDir.chapterDirs = chapterDirs
}
lastRenew = System.currentTimeMillis()
notifyChanges()
}
}
lastRenew = System.currentTimeMillis()
private fun getSources(): List<Source> {
return sourceManager.getOnlineSources() + sourceManager.getStubSources()
}
private fun notifyChanges() {
_state.value += 1
}
/**
* Returns a new map containing only the key entries of [transform] that are not null.
*/
private inline fun <K, V, R> Map<out K, V>.mapNotNullKeys(transform: (Map.Entry<K?, V>) -> R?): Map<R, V> {
private inline fun <K, V, R> Map<out K, V>.mapNotNullKeys(transform: (Map.Entry<K?, V>) -> R?): MutableMap<R, V> {
val destination = LinkedHashMap<R, V>()
forEach { element -> transform(element)?.let { destination[it] = element.value } }
return destination
}
/**
* Returns a map from a list containing only the key entries of [transform] that are not null.
*/
private inline fun <T, K, V> Array<T>.associateNotNullKeys(transform: (T) -> Pair<K?, V>): Map<K, V> {
val destination = LinkedHashMap<K, V>()
for (element in this) {
val (key, value) = transform(element)
if (key != null) {
destination[key] = value
}
}
return destination
}
}
/**
@ -283,7 +316,7 @@ class DownloadCache(
*/
private class RootDirectory(
val dir: UniFile,
var sourceDirs: Map<Long, SourceDirectory> = hashMapOf(),
var sourceDirs: MutableMap<Long, SourceDirectory> = mutableMapOf(),
)
/**
@ -291,7 +324,7 @@ private class RootDirectory(
*/
private class SourceDirectory(
val dir: UniFile,
var mangaDirs: Map<String, MangaDirectory> = hashMapOf(),
var mangaDirs: MutableMap<String, MangaDirectory> = mutableMapOf(),
)
/**
@ -299,5 +332,5 @@ private class SourceDirectory(
*/
private class MangaDirectory(
val dir: UniFile,
var chapterDirs: Set<String> = hashSetOf(),
var chapterDirs: MutableSet<String> = mutableSetOf(),
)

View file

@ -42,6 +42,9 @@ class ExtensionManager(
private val preferences: SourcePreferences = Injekt.get(),
) {
var isInitialized = false
private set
/**
* API where all the available extensions can be found.
*/
@ -102,6 +105,8 @@ class ExtensionManager(
_untrustedExtensionsFlow.value = extensions
.filterIsInstance<LoadResult.Untrusted>()
.map { it.extension }
isInitialized = true
}
/**

View file

@ -113,7 +113,7 @@ class SourceManager(
}
@Suppress("OverridingDeprecatedMember")
open inner class StubSource(val sourceData: SourceData) : Source {
open inner class StubSource(private val sourceData: SourceData) : Source {
override val id: Long = sourceData.id
@ -125,6 +125,7 @@ class SourceManager(
throw getSourceNotInstalledException()
}
@Deprecated("Use the 1.x API instead", replaceWith = ReplaceWith("getMangaDetails"))
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return Observable.error(getSourceNotInstalledException())
}
@ -133,6 +134,7 @@ class SourceManager(
throw getSourceNotInstalledException()
}
@Deprecated("Use the 1.x API instead", replaceWith = ReplaceWith("getChapterList"))
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
return Observable.error(getSourceNotInstalledException())
}
@ -141,6 +143,7 @@ class SourceManager(
throw getSourceNotInstalledException()
}
@Deprecated("Use the 1.x API instead", replaceWith = ReplaceWith("getPageList"))
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
return Observable.error(getSourceNotInstalledException())
}

View file

@ -39,6 +39,7 @@ import eu.kanade.presentation.library.components.LibraryToolbarTitle
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.models.toDomainManga
import eu.kanade.tachiyomi.data.download.DownloadCache
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.source.SourceManager
@ -88,6 +89,7 @@ class LibraryPresenter(
private val coverCache: CoverCache = Injekt.get(),
private val sourceManager: SourceManager = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(),
private val downloadCache: DownloadCache = Injekt.get(),
private val trackManager: TrackManager = Injekt.get(),
) : BasePresenter<LibraryController>(), LibraryState by state {
@ -338,7 +340,8 @@ class LibraryPresenter(
val libraryMangasFlow = combine(
getLibraryManga.subscribe(),
libraryPreferences.downloadBadge().changes(),
) { libraryMangaList, downloadBadgePref ->
downloadCache.changes,
) { libraryMangaList, downloadBadgePref, _ ->
libraryMangaList
.map { libraryManga ->
// Display mode based on user preference: take it from global library setting or category

View file

@ -392,7 +392,7 @@ class ReaderPresenter(
if (chapter.pageLoader is HttpPageLoader) {
val manga = manga ?: return
val dbChapter = chapter.chapter
val isDownloaded = downloadManager.isChapterDownloaded(dbChapter.name, dbChapter.scanlator, manga.title, manga.source)
val isDownloaded = downloadManager.isChapterDownloaded(dbChapter.name, dbChapter.scanlator, manga.title, manga.source, skipCache = true)
if (isDownloaded) {
chapter.state = ReaderChapter.State.Wait
}
@ -463,6 +463,7 @@ class ReaderPresenter(
nextChapter.scanlator,
manga.title,
manga.source,
skipCache = true,
) || downloadManager.getChapterDownloadOrNull(nextChapter) != null
if (isNextChapterDownloadedOrQueued) {
downloadAutoNextChapters(chaptersNumberToDownload, nextChapter.id, nextChapter.read)

View file

@ -57,6 +57,7 @@ class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: At
prevChapter.scanlator,
manga.title,
manga.source,
skipCache = true,
)
val isCurrentDownloaded = transition.from.pageLoader is DownloadPageLoader
binding.upperText.text = buildSpannedString {
@ -94,6 +95,7 @@ class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: At
nextChapter.scanlator,
manga.title,
manga.source,
skipCache = true,
)
binding.upperText.text = buildSpannedString {
bold { append(context.getString(R.string.transition_finished)) }