Browse Source

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
arkon 2 years ago
parent
commit
7e40680af0

+ 84 - 51
app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt

@@ -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
-
-        sourceDirs.values.forEach { sourceDir ->
-            val mangaDirs = sourceDir.dir.listFiles()
-                .orEmpty()
-                .associateNotNullKeys { it.name to MangaDirectory(it) }
-
-            sourceDir.mangaDirs = mangaDirs
+            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
+                }
 
-            mangaDirs.values.forEach { mangaDir ->
-                val chapterDirs = mangaDir.dir.listFiles()
-                    .orEmpty()
-                    .mapNotNull { chapterDir ->
-                        chapterDir.name
-                            ?.replace(".cbz", "")
-                            ?.takeUnless { it.endsWith(Downloader.TMP_DIR_SUFFIX) }
+            rootDownloadsDir.sourceDirs = sourceDirs
+
+            sourceDirs.values
+                .map { sourceDir ->
+                    async {
+                        val mangaDirs = sourceDir.dir.listFiles().orEmpty()
+                            .filterNot { it.name.isNullOrBlank() }
+                            .associate { it.name!! to MangaDirectory(it) }
+                            .toMutableMap()
+
+                        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(),
 )

+ 5 - 0
app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt

@@ -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
     }
 
     /**

+ 4 - 1
app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt

@@ -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())
         }

+ 4 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt

@@ -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

+ 2 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt

@@ -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)

+ 2 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderTransitionView.kt

@@ -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)) }