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.download.service.DownloadPreferences
import eu.kanade.domain.manga.model.Manga import eu.kanade.domain.manga.model.Manga
import eu.kanade.tachiyomi.data.database.models.Chapter 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.Source
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.util.lang.launchIO
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers 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.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withTimeout
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -26,9 +35,15 @@ class DownloadCache(
private val context: Context, private val context: Context,
private val provider: DownloadProvider = Injekt.get(), private val provider: DownloadProvider = Injekt.get(),
private val sourceManager: SourceManager = Injekt.get(), private val sourceManager: SourceManager = Injekt.get(),
private val extensionManager: ExtensionManager = Injekt.get(),
private val downloadPreferences: DownloadPreferences = 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) private val scope = CoroutineScope(Dispatchers.IO)
/** /**
@ -41,6 +56,7 @@ class DownloadCache(
* The last time the cache was refreshed. * The last time the cache was refreshed.
*/ */
private var lastRenew = 0L private var lastRenew = 0L
private var renewalJob: Job? = null
private var rootDownloadsDir = RootDirectory(getDirectoryFromPreference()) private var rootDownloadsDir = RootDirectory(getDirectoryFromPreference())
@ -134,6 +150,8 @@ class DownloadCache(
// Save the chapter directory // Save the chapter directory
mangaDir.chapterDirs += chapterDirName mangaDir.chapterDirs += chapterDirName
notifyChanges()
} }
/** /**
@ -151,6 +169,8 @@ class DownloadCache(
mangaDir.chapterDirs -= it mangaDir.chapterDirs -= it
} }
} }
notifyChanges()
} }
/** /**
@ -170,6 +190,8 @@ class DownloadCache(
} }
} }
} }
notifyChanges()
} }
/** /**
@ -184,6 +206,8 @@ class DownloadCache(
if (mangaDirName in sourceDir.mangaDirs) { if (mangaDirName in sourceDir.mangaDirs) {
sourceDir.mangaDirs -= mangaDirName sourceDir.mangaDirs -= mangaDirName
} }
notifyChanges()
} }
@Synchronized @Synchronized
@ -193,6 +217,8 @@ class DownloadCache(
sourceDir.delete() sourceDir.delete()
rootDownloadsDir.sourceDirs -= source.id rootDownloadsDir.sourceDirs -= source.id
} }
notifyChanges()
} }
/** /**
@ -206,76 +232,83 @@ class DownloadCache(
/** /**
* Renews the downloads cache. * Renews the downloads cache.
*/ */
@Synchronized
private fun renewCache() { 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 return
} }
val sources = sourceManager.getOnlineSources() + sourceManager.getStubSources() renewalJob = scope.launchIO {
var sources = getSources()
// Ensure we try again later if no sources have been loaded // Try to wait until extensions and sources have loaded
if (sources.isEmpty()) { withTimeout(30000L) {
return while (!extensionManager.isInitialized) {
} delay(2000L)
}
val sourceDirs = rootDownloadsDir.dir.listFiles() while (sources.isEmpty()) {
.orEmpty() delay(2000L)
.associate { it.name to SourceDirectory(it) } sources = getSources()
.mapNotNullKeys { entry -> }
sources.find {
provider.getSourceDirName(it).equals(entry.key, ignoreCase = true)
}?.id
} }
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 -> rootDownloadsDir.sourceDirs = sourceDirs
val mangaDirs = sourceDir.dir.listFiles()
.orEmpty()
.associateNotNullKeys { it.name to MangaDirectory(it) }
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 -> sourceDir.mangaDirs = mangaDirs
val chapterDirs = mangaDir.dir.listFiles()
.orEmpty() mangaDirs.values.forEach { mangaDir ->
.mapNotNull { chapterDir -> val chapterDirs = mangaDir.dir.listFiles().orEmpty()
chapterDir.name .mapNotNull { chapterDir ->
?.replace(".cbz", "") chapterDir.name
?.takeUnless { it.endsWith(Downloader.TMP_DIR_SUFFIX) } ?.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. * 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>() val destination = LinkedHashMap<R, V>()
forEach { element -> transform(element)?.let { destination[it] = element.value } } forEach { element -> transform(element)?.let { destination[it] = element.value } }
return destination 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( private class RootDirectory(
val dir: UniFile, 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( private class SourceDirectory(
val dir: UniFile, 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( private class MangaDirectory(
val dir: UniFile, 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(), private val preferences: SourcePreferences = Injekt.get(),
) { ) {
var isInitialized = false
private set
/** /**
* API where all the available extensions can be found. * API where all the available extensions can be found.
*/ */
@ -102,6 +105,8 @@ class ExtensionManager(
_untrustedExtensionsFlow.value = extensions _untrustedExtensionsFlow.value = extensions
.filterIsInstance<LoadResult.Untrusted>() .filterIsInstance<LoadResult.Untrusted>()
.map { it.extension } .map { it.extension }
isInitialized = true
} }
/** /**

View File

@ -113,7 +113,7 @@ class SourceManager(
} }
@Suppress("OverridingDeprecatedMember") @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 override val id: Long = sourceData.id
@ -125,6 +125,7 @@ class SourceManager(
throw getSourceNotInstalledException() throw getSourceNotInstalledException()
} }
@Deprecated("Use the 1.x API instead", replaceWith = ReplaceWith("getMangaDetails"))
override fun fetchMangaDetails(manga: SManga): Observable<SManga> { override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return Observable.error(getSourceNotInstalledException()) return Observable.error(getSourceNotInstalledException())
} }
@ -133,6 +134,7 @@ class SourceManager(
throw getSourceNotInstalledException() throw getSourceNotInstalledException()
} }
@Deprecated("Use the 1.x API instead", replaceWith = ReplaceWith("getChapterList"))
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> { override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
return Observable.error(getSourceNotInstalledException()) return Observable.error(getSourceNotInstalledException())
} }
@ -141,6 +143,7 @@ class SourceManager(
throw getSourceNotInstalledException() throw getSourceNotInstalledException()
} }
@Deprecated("Use the 1.x API instead", replaceWith = ReplaceWith("getPageList"))
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> { override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
return Observable.error(getSourceNotInstalledException()) 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.R
import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.models.toDomainManga 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.download.DownloadManager
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
@ -88,6 +89,7 @@ class LibraryPresenter(
private val coverCache: CoverCache = Injekt.get(), private val coverCache: CoverCache = Injekt.get(),
private val sourceManager: SourceManager = Injekt.get(), private val sourceManager: SourceManager = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(), private val downloadManager: DownloadManager = Injekt.get(),
private val downloadCache: DownloadCache = Injekt.get(),
private val trackManager: TrackManager = Injekt.get(), private val trackManager: TrackManager = Injekt.get(),
) : BasePresenter<LibraryController>(), LibraryState by state { ) : BasePresenter<LibraryController>(), LibraryState by state {
@ -338,7 +340,8 @@ class LibraryPresenter(
val libraryMangasFlow = combine( val libraryMangasFlow = combine(
getLibraryManga.subscribe(), getLibraryManga.subscribe(),
libraryPreferences.downloadBadge().changes(), libraryPreferences.downloadBadge().changes(),
) { libraryMangaList, downloadBadgePref -> downloadCache.changes,
) { libraryMangaList, downloadBadgePref, _ ->
libraryMangaList libraryMangaList
.map { libraryManga -> .map { libraryManga ->
// Display mode based on user preference: take it from global library setting or category // 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) { if (chapter.pageLoader is HttpPageLoader) {
val manga = manga ?: return val manga = manga ?: return
val dbChapter = chapter.chapter 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) { if (isDownloaded) {
chapter.state = ReaderChapter.State.Wait chapter.state = ReaderChapter.State.Wait
} }
@ -463,6 +463,7 @@ class ReaderPresenter(
nextChapter.scanlator, nextChapter.scanlator,
manga.title, manga.title,
manga.source, manga.source,
skipCache = true,
) || downloadManager.getChapterDownloadOrNull(nextChapter) != null ) || downloadManager.getChapterDownloadOrNull(nextChapter) != null
if (isNextChapterDownloadedOrQueued) { if (isNextChapterDownloadedOrQueued) {
downloadAutoNextChapters(chaptersNumberToDownload, nextChapter.id, nextChapter.read) downloadAutoNextChapters(chaptersNumberToDownload, nextChapter.id, nextChapter.read)

View File

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