diff --git a/komga-webui/package-lock.json b/komga-webui/package-lock.json
index 5d1064e62..380eee9df 100644
--- a/komga-webui/package-lock.json
+++ b/komga-webui/package-lock.json
@@ -12,6 +12,7 @@
"core-js": "^3.6.5",
"date-fns": "^2.19.0",
"jquery": "^3.5.1",
+ "js-file-downloader": "^1.1.16",
"language-tags": "^1.0.5",
"lodash": "^4.17.19",
"qs": "^6.9.4",
@@ -13480,6 +13481,11 @@
"node": ">=10"
}
},
+ "node_modules/js-file-downloader": {
+ "version": "1.1.16",
+ "resolved": "https://registry.npmjs.org/js-file-downloader/-/js-file-downloader-1.1.16.tgz",
+ "integrity": "sha512-vj4ZpHvFJI7J7SluyreHzaAVZDrPulRcwjMMOUve1KOEB4oxYAiQZXzgmu2lPbMTqKlf7U91MNZYRMTq7DsMfQ=="
+ },
"node_modules/js-message": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/js-message/-/js-message-1.0.5.tgz",
@@ -32242,6 +32248,11 @@
}
}
},
+ "js-file-downloader": {
+ "version": "1.1.16",
+ "resolved": "https://registry.npmjs.org/js-file-downloader/-/js-file-downloader-1.1.16.tgz",
+ "integrity": "sha512-vj4ZpHvFJI7J7SluyreHzaAVZDrPulRcwjMMOUve1KOEB4oxYAiQZXzgmu2lPbMTqKlf7U91MNZYRMTq7DsMfQ=="
+ },
"js-message": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/js-message/-/js-message-1.0.5.tgz",
diff --git a/komga-webui/package.json b/komga-webui/package.json
index 6068351e9..3bba07955 100644
--- a/komga-webui/package.json
+++ b/komga-webui/package.json
@@ -15,6 +15,7 @@
"core-js": "^3.6.5",
"date-fns": "^2.19.0",
"jquery": "^3.5.1",
+ "js-file-downloader": "^1.1.16",
"language-tags": "^1.0.5",
"lodash": "^4.17.19",
"qs": "^6.9.4",
diff --git a/komga-webui/src/locales/en.json b/komga-webui/src/locales/en.json
index 92c632b79..aafb92a01 100644
--- a/komga-webui/src/locales/en.json
+++ b/komga-webui/src/locales/en.json
@@ -37,6 +37,7 @@
"cycling_page_layout": "Cycling Page Layout",
"cycling_scale": "Cycling Scale",
"cycling_side_padding": "Cycling Side Padding",
+ "download_current_page": "Download current page",
"end_of_book": "You've reached the end of the book.",
"from_series_metadata": "from series metadata",
"move_next": "Click or press \"Next\" again to move to the next book.",
diff --git a/komga-webui/src/views/BookReader.vue b/komga-webui/src/views/BookReader.vue
index a03655bdf..ac7d77cc6 100644
--- a/komga-webui/src/views/BookReader.vue
+++ b/komga-webui/src/views/BookReader.vue
@@ -38,6 +38,19 @@
>
mdi-cog
+
+
+
+
+ mdi-dots-vertical
+
+
+
+
+ {{ $t('bookreader.download_current_page') }}
+
+
+
@@ -290,6 +303,7 @@ import {shortcutsSettingsContinuous} from '@/functions/shortcuts/continuous-read
import {BookDto, PageDto, PageDtoWithUrl} from '@/types/komga-books'
import {Context, ContextOrigin} from '@/types/context'
import {SeriesDto} from "@/types/komga-series";
+import jsFileDownloader from "js-file-downloader"
const cookieFit = 'webreader.fit'
const cookieContinuousReaderFit = 'webreader.continuousReaderFit'
@@ -484,6 +498,9 @@ export default Vue.extend({
contextReadList (): boolean {
return this.context.origin === ContextOrigin.READLIST
},
+ currentPage(): PageDtoWithUrl {
+ return this.pages[this.page - 1]
+ },
animations: {
get: function (): boolean {
@@ -782,6 +799,12 @@ export default Vue.extend({
async markProgress (page: number) {
await this.$komgaBooks.updateReadProgress(this.bookId, { page: page })
},
+ downloadCurrentPage() {
+ new jsFileDownloader({
+ url: this.currentPage.url,
+ withCredentials: true,
+ })
+ },
},
})
diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/ContentDetector.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/ContentDetector.kt
index 8f79842c3..47204a65a 100644
--- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/ContentDetector.kt
+++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/ContentDetector.kt
@@ -36,4 +36,11 @@ class ContentDetector(
fun isImage(mediaType: String): Boolean =
mediaType.startsWith("image/")
+
+ fun mediaTypeToExtension(mediaType: String): String? =
+ try {
+ tika.mimeRepository.forName(mediaType).extension
+ } catch (e: Exception) {
+ null
+ }
}
diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt
index 484e586a0..6e119fb2e 100644
--- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt
+++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt
@@ -24,6 +24,7 @@ import org.gotson.komga.domain.persistence.ReadListRepository
import org.gotson.komga.domain.service.BookLifecycle
import org.gotson.komga.infrastructure.image.ImageType
import org.gotson.komga.infrastructure.jooq.UnpagedSorted
+import org.gotson.komga.infrastructure.mediacontainer.ContentDetector
import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam
import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam
@@ -78,7 +79,8 @@ class BookController(
private val bookMetadataRepository: BookMetadataRepository,
private val mediaRepository: MediaRepository,
private val bookDtoRepository: BookDtoRepository,
- private val readListRepository: ReadListRepository
+ private val readListRepository: ReadListRepository,
+ private val contentDetector: ContentDetector,
) {
@PageableAsQueryParam
@@ -342,6 +344,15 @@ class BookController(
val pageContent = bookLifecycle.getBookPage(book, pageNum, convertFormat)
ResponseEntity.ok()
+ .headers(
+ HttpHeaders().apply {
+ val extension = contentDetector.mediaTypeToExtension(pageContent.mediaType) ?: "jpeg"
+ val imageFileName = "${book.name}-$pageNum$extension"
+ contentDisposition = ContentDisposition.builder("inline")
+ .filename(imageFileName)
+ .build()
+ }
+ )
.contentType(getMediaTypeOrDefault(pageContent.mediaType))
.setNotModified(media)
.body(pageContent.content)