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>
This commit is contained in:
@@ -11,6 +11,8 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
|
|||||||
- `Other` - for technical stuff.
|
- `Other` - for technical stuff.
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [v0.20.0] - 2026-06-27
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -362,11 +362,6 @@ class Downloader(
|
|||||||
reIndexedPages
|
reIndexedPages
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete all temporary (unfinished) files
|
|
||||||
tmpDir.listFiles()
|
|
||||||
?.filter { it.extension == "tmp" }
|
|
||||||
?.forEach { it.delete() }
|
|
||||||
|
|
||||||
download.status = Download.State.DOWNLOADING
|
download.status = Download.State.DOWNLOADING
|
||||||
|
|
||||||
// Start downloading images, consider we can have downloaded images already
|
// 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 digitCount = (download.pages?.size ?: 0).toString().length.coerceAtLeast(3)
|
||||||
val filename = "%0${digitCount}d".format(Locale.ENGLISH, page.number)
|
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
|
// Try to find the image file
|
||||||
val imageFile = tmpDir.listFiles()?.firstOrNull {
|
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 {
|
try {
|
||||||
@@ -488,15 +481,27 @@ class Downloader(
|
|||||||
page.status = Page.State.DownloadImage
|
page.status = Page.State.DownloadImage
|
||||||
page.progress = 0
|
page.progress = 0
|
||||||
return flow {
|
return flow {
|
||||||
val response = source.getImage(page)
|
val file = tmpDir.findFile("$filename.tmp")
|
||||||
val file = tmpDir.createFile("$filename.tmp")!!
|
?: tmpDir.createFile("$filename.tmp")!!
|
||||||
|
|
||||||
|
val response = source.getImage(page, file.length())
|
||||||
|
|
||||||
try {
|
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)
|
val extension = getImageExtension(response, file)
|
||||||
file.renameTo("$filename.$extension")
|
file.renameTo("$filename.$extension")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
response.close()
|
response.close()
|
||||||
|
if (response.code == 416) {
|
||||||
file.delete()
|
file.delete()
|
||||||
|
}
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
emit(file)
|
emit(file)
|
||||||
@@ -521,6 +526,8 @@ class Downloader(
|
|||||||
* @param filename the filename of the image.
|
* @param filename the filename of the image.
|
||||||
*/
|
*/
|
||||||
private fun copyImageFromCache(cacheFile: File, tmpDir: UniFile, filename: String): UniFile {
|
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")!!
|
val tmpFile = tmpDir.createFile("$filename.tmp")!!
|
||||||
cacheFile.inputStream().use { input ->
|
cacheFile.inputStream().use { input ->
|
||||||
tmpFile.openOutputStream().use { output ->
|
tmpFile.openOutputStream().use { output ->
|
||||||
|
|||||||
@@ -118,13 +118,26 @@ suspend fun Call.awaitSuccess(): Response {
|
|||||||
return 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()
|
val progressClient = newBuilder()
|
||||||
.cache(null)
|
.cache(null)
|
||||||
.addNetworkInterceptor { chain ->
|
.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()
|
originalResponse.newBuilder()
|
||||||
.body(ProgressResponseBody(originalResponse.body, listener))
|
.body(ProgressResponseBody(originalResponse.body, listener, existingSize))
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import java.io.IOException
|
|||||||
class ProgressResponseBody(
|
class ProgressResponseBody(
|
||||||
private val responseBody: ResponseBody,
|
private val responseBody: ResponseBody,
|
||||||
private val progressListener: ProgressListener,
|
private val progressListener: ProgressListener,
|
||||||
|
private val existingSize: Long, // bytes already downloaded
|
||||||
) : ResponseBody() {
|
) : ResponseBody() {
|
||||||
|
|
||||||
private val bufferedSource: BufferedSource by lazy {
|
private val bufferedSource: BufferedSource by lazy {
|
||||||
@@ -32,7 +33,7 @@ class ProgressResponseBody(
|
|||||||
|
|
||||||
private fun source(source: Source): Source {
|
private fun source(source: Source): Source {
|
||||||
return object : ForwardingSource(source) {
|
return object : ForwardingSource(source) {
|
||||||
var totalBytesRead = 0L
|
var totalBytesRead = existingSize
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override fun read(sink: Buffer, byteCount: Long): Long {
|
override fun read(sink: Buffer, byteCount: Long): Long {
|
||||||
|
|||||||
@@ -404,8 +404,8 @@ abstract class HttpSource : CatalogueSource {
|
|||||||
)
|
)
|
||||||
protected open fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
|
protected open fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
|
||||||
|
|
||||||
suspend fun getImage(page: Page): Response {
|
suspend fun getImage(page: Page, existingSize: Long = 0L): Response {
|
||||||
return client.newCachelessCallWithProgress(imageRequest(page), page)
|
return client.newCachelessCallWithProgress(imageRequest(page), page, existingSize)
|
||||||
.awaitSuccess()
|
.awaitSuccess()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user