From d8c3440d3793573c1ea52b85d00a6ced03983668 Mon Sep 17 00:00:00 2001 From: Mohannad <134175138+xMohnad@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:20:43 +0300 Subject: [PATCH] Support resumable image downloads if supported by source (#3167) Co-authored-by: MajorTanya <39014446+MajorTanya@users.noreply.github.com> Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com> --- CHANGELOG.md | 2 ++ .../tachiyomi/data/download/Downloader.kt | 35 +++++++++++-------- .../tachiyomi/network/OkHttpExtensions.kt | 19 ++++++++-- .../tachiyomi/network/ProgressResponseBody.kt | 3 +- .../tachiyomi/source/online/HttpSource.kt | 4 +-- 5 files changed, 43 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81ded046a..3298eb962 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co - `Other` - for technical stuff. ## [Unreleased] +### Added +- Support resumable image downloads if supported by source ([@xMohnad](https://github.com/xMohnad)) ([#3167](https://github.com/mihonapp/mihon/pull/3167)) ## [v0.20.0] - 2026-06-27 ### Added diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt index ebcfb58ab..b2306b625 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt @@ -362,11 +362,6 @@ class Downloader( reIndexedPages } - // Delete all temporary (unfinished) files - tmpDir.listFiles() - ?.filter { it.extension == "tmp" } - ?.forEach { it.delete() } - download.status = Download.State.DOWNLOADING // Start downloading images, consider we can have downloaded images already @@ -441,14 +436,12 @@ class Downloader( val digitCount = (download.pages?.size ?: 0).toString().length.coerceAtLeast(3) val filename = "%0${digitCount}d".format(Locale.ENGLISH, page.number) - val tmpFile = tmpDir.findFile("$filename.tmp") - - // Delete temp file if it exists - tmpFile?.delete() // Try to find the image file val imageFile = tmpDir.listFiles()?.firstOrNull { - it.name!!.startsWith("$filename.") || it.name!!.startsWith("${filename}__001") + val filename = it.name + if (filename == null || filename.endsWith(".tmp")) return@firstOrNull false + filename.startsWith("$filename.") || filename.startsWith("${filename}__001") } try { @@ -488,15 +481,27 @@ class Downloader( page.status = Page.State.DownloadImage page.progress = 0 return flow { - val response = source.getImage(page) - val file = tmpDir.createFile("$filename.tmp")!! + val file = tmpDir.findFile("$filename.tmp") + ?: tmpDir.createFile("$filename.tmp")!! + + val response = source.getImage(page, file.length()) + try { - response.body.source().saveTo(file.openOutputStream()) + response.body + .source() + .saveTo( + // If the server supports partial downloads (HTTP 206), + // append to the existing file. + // Otherwise, start from scratch and overwrite the file. + file.openOutputStream(response.code == 206), + ) val extension = getImageExtension(response, file) file.renameTo("$filename.$extension") } catch (e: Exception) { response.close() - file.delete() + if (response.code == 416) { + file.delete() + } throw e } emit(file) @@ -521,6 +526,8 @@ class Downloader( * @param filename the filename of the image. */ private fun copyImageFromCache(cacheFile: File, tmpDir: UniFile, filename: String): UniFile { + // Delete temp file if it exists + tmpDir.findFile("$filename.tmp")?.delete() val tmpFile = tmpDir.createFile("$filename.tmp")!! cacheFile.inputStream().use { input -> tmpFile.openOutputStream().use { output -> diff --git a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/OkHttpExtensions.kt b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/OkHttpExtensions.kt index 97b0774e4..1394c24d2 100644 --- a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/OkHttpExtensions.kt +++ b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/OkHttpExtensions.kt @@ -118,13 +118,26 @@ suspend fun Call.awaitSuccess(): Response { return response } -fun OkHttpClient.newCachelessCallWithProgress(request: Request, listener: ProgressListener): Call { +fun OkHttpClient.newCachelessCallWithProgress( + request: Request, + listener: ProgressListener, + existingSize: Long = 0L, +): Call { val progressClient = newBuilder() .cache(null) .addNetworkInterceptor { chain -> - val originalResponse = chain.proceed(chain.request()) + val request = chain.request() + .newBuilder() + .apply { + if (existingSize > 0 && request.header("Range") == null) { + header("Range", "bytes=$existingSize-") + } + } + .build() + + val originalResponse = chain.proceed(request) originalResponse.newBuilder() - .body(ProgressResponseBody(originalResponse.body, listener)) + .body(ProgressResponseBody(originalResponse.body, listener, existingSize)) .build() } .build() diff --git a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/ProgressResponseBody.kt b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/ProgressResponseBody.kt index 6ba53b197..05adacf4e 100644 --- a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/ProgressResponseBody.kt +++ b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/ProgressResponseBody.kt @@ -12,6 +12,7 @@ import java.io.IOException class ProgressResponseBody( private val responseBody: ResponseBody, private val progressListener: ProgressListener, + private val existingSize: Long, // bytes already downloaded ) : ResponseBody() { private val bufferedSource: BufferedSource by lazy { @@ -32,7 +33,7 @@ class ProgressResponseBody( private fun source(source: Source): Source { return object : ForwardingSource(source) { - var totalBytesRead = 0L + var totalBytesRead = existingSize @Throws(IOException::class) override fun read(sink: Buffer, byteCount: Long): Long { diff --git a/source-api/src/commonMain/kotlin/eu/kanade/tachiyomi/source/online/HttpSource.kt b/source-api/src/commonMain/kotlin/eu/kanade/tachiyomi/source/online/HttpSource.kt index 73817cd4d..95b9d612e 100644 --- a/source-api/src/commonMain/kotlin/eu/kanade/tachiyomi/source/online/HttpSource.kt +++ b/source-api/src/commonMain/kotlin/eu/kanade/tachiyomi/source/online/HttpSource.kt @@ -404,8 +404,8 @@ abstract class HttpSource : CatalogueSource { ) protected open fun imageUrlParse(response: Response): String = throw UnsupportedOperationException() - suspend fun getImage(page: Page): Response { - return client.newCachelessCallWithProgress(imageRequest(page), page) + suspend fun getImage(page: Page, existingSize: Long = 0L): Response { + return client.newCachelessCallWithProgress(imageRequest(page), page, existingSize) .awaitSuccess() }