diff --git a/app/src/main/java/eu/kanade/tachiyomi/App.kt b/app/src/main/java/eu/kanade/tachiyomi/App.kt index 29a17c8b7..cf2dfa742 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/App.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/App.kt @@ -31,6 +31,7 @@ import eu.kanade.tachiyomi.crash.CrashActivity import eu.kanade.tachiyomi.crash.GlobalExceptionHandler import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.coil.BufferedSourceFetcher +import eu.kanade.tachiyomi.data.coil.CoverRequestInterceptor import eu.kanade.tachiyomi.data.coil.ImageDecoder import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher import eu.kanade.tachiyomi.data.coil.MangaCoverKeyer @@ -214,6 +215,8 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor // Keyer add(MangaCoverKeyer(coverCache)) add(MangaKeyer()) + // Interceptor + add(CoverRequestInterceptor()) } memoryCache( diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/coil/CoverRequestInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/coil/CoverRequestInterceptor.kt new file mode 100644 index 000000000..25ad88357 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/coil/CoverRequestInterceptor.kt @@ -0,0 +1,24 @@ +package eu.kanade.tachiyomi.data.coil + +import coil3.intercept.Interceptor +import coil3.request.ImageResult +import coil3.request.SuccessResult +import tachiyomi.domain.manga.model.Manga +import tachiyomi.domain.manga.model.MangaCover + +/** + * De-duplicates concurrent requests for the same cover at the same size. Lists like updates show an entry's cover on + * every row, and requests that start together would each miss the memory cache and decode the cover again, so the + * waiting ones are served from the memory cache once the first one finishes. + */ +class CoverRequestInterceptor : Interceptor { + + private val concurrentRequests = DeDupeConcurrentRequests { it is SuccessResult } + + override suspend fun intercept(chain: Interceptor.Chain): ImageResult { + val data = chain.request.data + if (data !is MangaCover && data !is Manga) return chain.proceed() + + return concurrentRequests.apply(data to chain.size) { chain.proceed() } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/coil/DeDupeConcurrentRequests.kt b/app/src/main/java/eu/kanade/tachiyomi/data/coil/DeDupeConcurrentRequests.kt new file mode 100644 index 000000000..8501359bf --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/coil/DeDupeConcurrentRequests.kt @@ -0,0 +1,45 @@ +package eu.kanade.tachiyomi.data.coil + +import kotlinx.coroutines.CompletableDeferred + +/** + * De-duplicates concurrent requests for the same key, like Coil's `DeDupeConcurrentRequestStrategy` but for any + * result, so it works for decoded images as well as fetches. + * + * The first caller runs `block`. If it succeeds, the waiting callers run `block` too, for example to read what the + * first one cached. If it fails or is canceled, one of them runs it instead. + * + * @param isSuccess whether a result counts as a success, for results that report failure without throwing. + */ +class DeDupeConcurrentRequests( + private val isSuccess: (T) -> Boolean = { true }, +) { + private val requests = mutableMapOf>() + + suspend fun apply(key: Any, block: suspend () -> T): T { + while (true) { + var isLeader = false + val request = synchronized(requests) { + requests.getOrPut(key) { + isLeader = true + CompletableDeferred() + } + } + + if (!isLeader) { + if (request.await()) return block() + continue + } + + var succeeded = false + try { + return block().also { succeeded = isSuccess(it) } + } finally { + synchronized(requests) { + requests -= key + } + request.complete(succeeded) + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/coil/MangaCoverFetcher.kt b/app/src/main/java/eu/kanade/tachiyomi/data/coil/MangaCoverFetcher.kt index 98d9d29f9..00a364a45 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/coil/MangaCoverFetcher.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/coil/MangaCoverFetcher.kt @@ -74,7 +74,8 @@ class MangaCoverFetcher( return when (getResourceType(url)) { Type.File -> fileLoader(File(url.substringAfter("file://"))) Type.URI -> fileUriLoader(url) - Type.URL -> httpLoader() + // Requests for the same cover at different sizes or through Manga and MangaCover would each download it + Type.URL -> concurrentRequests.apply(diskCacheKey) { httpLoader() } null -> error("Invalid image") } } @@ -346,5 +347,7 @@ class MangaCoverFetcher( private val CACHE_CONTROL_NO_NETWORK_NO_CACHE = CacheControl.Builder().noCache().onlyIfCached().build() private const val HTTP_NOT_MODIFIED = 304 + + private val concurrentRequests = DeDupeConcurrentRequests() } }