fix(api): optimize HEAD requests for chapter downloads (#1601)

Previously, handling a HEAD request on the chapter download endpoint was inefficient as it triggered the full CBZ file generation process in-memory just to retrieve metadata like Content-Length and Content-Disposition. This caused unnecessary latency especially for OPDS clients.

This commit introduces a separate, lightweight path for HEAD requests.

- A new `getCbzMetadataForDownload` method is added to `ChapterDownloadHelper` to calculate the filename and file size without generating an archive stream.
- The `ChaptersFilesProvider` interface is updated with a `getArchiveSize()` method, implemented by both `ArchiveProvider` and `FolderProvider`, to retrieve the total size.
- The `MangaController` now differentiates between GET and HEAD methods, invoking the appropriate helper to ensure HEAD requests are served instantly with only the required metadata.
This commit is contained in:
Zeedif
2025-08-20 16:03:26 -06:00
committed by GitHub
parent 392f66e938
commit a414860626
5 changed files with 56 additions and 13 deletions

View File

@@ -476,24 +476,31 @@ object MangaController {
documentWith = {
withOperation {
summary("Download chapter as CBZ")
description("Get the CBZ file of the specified chapter")
description("Get the CBZ file of the specified chapter, or its metadata via a HEAD request.")
}
},
behaviorOf = { ctx, chapterId, markAsRead ->
val shouldMarkAsRead = if (ctx.method() == HandlerType.HEAD) false else markAsRead
ctx.future {
future { ChapterDownloadHelper.getCbzForDownload(chapterId, shouldMarkAsRead) }
.thenApply { (inputStream, fileName, fileSize) ->
ctx.header("Content-Type", "application/vnd.comicbook+zip")
ctx.header("Content-Disposition", "attachment; filename=\"$fileName\"")
ctx.header("Content-Length", fileSize.toString())
if (ctx.method() == HandlerType.HEAD) {
inputStream.close()
ctx.status(200)
} else {
if (ctx.method() == HandlerType.HEAD) {
ctx.future {
future { ChapterDownloadHelper.getCbzMetadataForDownload(chapterId) }
.thenApply { (fileName, fileSize, contentType) ->
ctx.header("Content-Type", contentType)
ctx.header("Content-Disposition", "attachment; filename=\"$fileName\"")
ctx.header("Content-Length", fileSize.toString())
ctx.status(HttpStatus.OK)
}
}
} else {
val shouldMarkAsRead = markAsRead ?: false
ctx.future {
future { ChapterDownloadHelper.getCbzForDownload(chapterId, shouldMarkAsRead) }
.thenApply { (inputStream, fileName, fileSize) ->
ctx.header("Content-Type", "application/vnd.comicbook+zip")
ctx.header("Content-Disposition", "attachment; filename=\"$fileName\"")
ctx.header("Content-Length", fileSize.toString())
ctx.result(inputStream)
}
}
}
}
},
withResults = {

View File

@@ -94,4 +94,26 @@ object ChapterDownloadHelper {
return Triple(cbzFile.first, fileName, cbzFile.second)
}
fun getCbzMetadataForDownload(chapterId: Int): Triple<String, Long, String> { // fileName, fileSize, contentType
val (chapterData, mangaTitle) =
transaction {
val row =
(ChapterTable innerJoin MangaTable)
.select(ChapterTable.columns + MangaTable.columns)
.where { ChapterTable.id eq chapterId }
.firstOrNull() ?: throw IllegalArgumentException("ChapterId $chapterId not found")
val chapter = ChapterTable.toDataClass(row)
val title = row[MangaTable.title]
Pair(chapter, title)
}
val scanlatorPart = chapterData.scanlator?.let { "[$it] " } ?: ""
val fileName = "$mangaTitle - $scanlatorPart${chapterData.name}.cbz"
val fileSize = provider(chapterData.mangaId, chapterData.id).getArchiveSize()
val contentType = "application/vnd.comicbook+zip"
return Triple(fileName, fileSize, contentType)
}
}

View File

@@ -220,6 +220,8 @@ abstract class ChaptersFilesProvider<Type : FileType>(
abstract fun getAsArchiveStream(): Pair<InputStream, Long>
abstract fun getArchiveSize(): Long
private fun maybeConvertPages(chapterCacheFolder: File) {
val conversions = serverConfig.downloadConversions.value

View File

@@ -108,6 +108,11 @@ class ArchiveProvider(
return cbzFile.inputStream() to cbzFile.length()
}
override fun getArchiveSize(): Long {
val cbzFile = File(getChapterCbzPath(mangaId, chapterId))
return if (cbzFile.exists()) cbzFile.length() else 0L
}
private fun extractCbzFile(
cbzFile: File,
chapterFolder: File,

View File

@@ -101,4 +101,11 @@ class FolderProvider(
val zipData = byteArrayOutputStream.toByteArray()
return ByteArrayInputStream(zipData) to zipData.size.toLong()
}
override fun getArchiveSize(): Long {
val chapterDir = File(getChapterDownloadPath(mangaId, chapterId))
if (!chapterDir.exists() || !chapterDir.isDirectory) return 0L
// Approximation: actual CBZ size is slightly larger due to ZIP metadata, but sufficient for Content-Length header.
return chapterDir.listFiles()?.filter { it.isFile }?.sumOf { it.length() } ?: 0L
}
}