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 + + + + + + {{ $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)