From 08cd0899146479ba6495a6f8e8fa8880656c25e0 Mon Sep 17 00:00:00 2001 From: michael-howard Date: Sat, 18 Apr 2026 21:44:52 -0700 Subject: [PATCH] feat(api): support HTTP Range requests on book file download endpoint Add HTTP/1.1 Range request support (RFC 7233) to the book file endpoint shared across the REST API and OPDS v1/v2: - All responses now include Accept-Ranges: bytes so clients know partial content is available - A single-range request (e.g. bytes=0-131071) returns 206 Partial Content with the matching Content-Range header, using RandomAccessFile.seek() for efficient non-sequential access - Multi-range requests fall back to a full 200 response (multipart/byteranges is out of scope) - A malformed or unsatisfiable range returns 416 Range Not Satisfiable with a Content-Range: bytes */fileLength header as required by RFC 7233 - KoboController continues to call getBookFileInternal without a range header via the new default-null parameter; no change in behaviour there Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../interfaces/api/CommonBookController.kt | 86 ++++++++++++++++--- 1 file changed, 72 insertions(+), 14 deletions(-) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/CommonBookController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/CommonBookController.kt index d9fbf9dc..5c75d7d7 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/CommonBookController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/CommonBookController.kt @@ -32,6 +32,7 @@ import org.gotson.komga.interfaces.api.persistence.BookDtoRepository import org.springframework.core.io.FileSystemResource import org.springframework.http.ContentDisposition import org.springframework.http.HttpHeaders +import org.springframework.http.HttpRange import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.http.ResponseEntity @@ -41,6 +42,7 @@ import org.springframework.util.MimeTypeUtils import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestHeader import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.ResponseStatus @@ -328,37 +330,93 @@ class CommonBookController( fun downloadBookFile( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable bookId: String, - ): ResponseEntity = getBookFileInternal(principal, bookId) + @RequestHeader(HttpHeaders.RANGE, required = false) rangeHeader: String?, + ): ResponseEntity = getBookFileInternal(principal, bookId, rangeHeader) fun getBookFileInternal( principal: KomgaPrincipal, bookId: String, + rangeHeader: String? = null, ): ResponseEntity = bookRepository.findByIdOrNull(bookId)?.let { book -> contentRestrictionChecker.checkContentRestriction(principal.user, book) try { val media = mediaRepository.findById(book.id) - with(FileSystemResource(book.path)) { - if (!exists()) throw FileNotFoundException(path) + val resource = FileSystemResource(book.path) + if (!resource.exists()) throw FileNotFoundException(resource.path) + val fileLength = resource.contentLength() + + val baseHeaders = + HttpHeaders().apply { + contentDisposition = + ContentDisposition + .builder("attachment") + .filename(book.path.name, StandardCharsets.UTF_8) + .build() + set(HttpHeaders.ACCEPT_RANGES, "bytes") + } + + // Parse range — only honour a single range; fall back to full response for multi-range + val parsedRanges = + if (rangeHeader != null) { + try { + HttpRange.parseRanges(rangeHeader) + } catch (ex: IllegalArgumentException) { + return@let ResponseEntity + .status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE) + .headers(baseHeaders.apply { set(HttpHeaders.CONTENT_RANGE, "bytes */$fileLength") }) + .build() + } + } else { + null + } + + if (parsedRanges != null && parsedRanges.size == 1) { + val (start, end) = + try { + parsedRanges[0].getRangeStart(fileLength) to parsedRanges[0].getRangeEnd(fileLength) + } catch (ex: IllegalArgumentException) { + return@let ResponseEntity + .status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE) + .headers(baseHeaders.apply { set(HttpHeaders.CONTENT_RANGE, "bytes */$fileLength") }) + .build() + } + val rangeLength = end - start + 1 val stream = StreamingResponseBody { os: OutputStream -> - this.inputStream.use { + java.io.RandomAccessFile(resource.file, "r").use { raf -> + raf.seek(start) + val buffer = ByteArray(8192) + var remaining = rangeLength + while (remaining > 0) { + val toRead = minOf(buffer.size.toLong(), remaining).toInt() + val read = raf.read(buffer, 0, toRead) + if (read == -1) break + os.write(buffer, 0, read) + remaining -= read + } + } + } + ResponseEntity + .status(HttpStatus.PARTIAL_CONTENT) + .headers(baseHeaders.apply { set(HttpHeaders.CONTENT_RANGE, "bytes $start-$end/$fileLength") }) + .contentType(getMediaTypeOrDefault(media.mediaType)) + .contentLength(rangeLength) + .body(stream) + } else { + // No range header or multi-range: stream the full file + val stream = + StreamingResponseBody { os: OutputStream -> + resource.inputStream.use { IOUtils.copyLarge(it, os, ByteArray(8192)) os.close() } } ResponseEntity .ok() - .headers( - HttpHeaders().apply { - contentDisposition = - ContentDisposition - .builder("attachment") - .filename(book.path.name, StandardCharsets.UTF_8) - .build() - }, - ).contentType(getMediaTypeOrDefault(media.mediaType)) - .contentLength(this.contentLength()) + .headers(baseHeaders) + .contentType(getMediaTypeOrDefault(media.mediaType)) + .contentLength(fileLength) .body(stream) } } catch (ex: FileNotFoundException) {