From 5fa789b8d8ddded96db6bae5e49cd55a9acb9900 Mon Sep 17 00:00:00 2001 From: Gauthier Roebroeck Date: Mon, 9 Oct 2023 15:56:08 +0800 Subject: [PATCH] feat(api): add thumbnail size server setting Closes: #1031 Closes: #861 --- .../komga/domain/model/ThumbnailSize.kt | 8 +++++ .../komga/domain/service/BookAnalyzer.kt | 9 +++--- .../komga/domain/service/BookLifecycle.kt | 4 +-- .../configuration/KomgaSettingsProvider.kt | 11 +++++++ .../infrastructure/image/ImageConverter.kt | 8 +++-- .../komga/infrastructure/image/ImageType.kt | 4 +-- .../interfaces/api/opds/v1/OpdsController.kt | 4 ++- .../interfaces/api/rest/SettingsController.kt | 4 +++ .../interfaces/api/rest/dto/SettingsDto.kt | 1 + .../api/rest/dto/SettingsUpdateDto.kt | 1 + .../api/rest/dto/ThumbnailSizeDto.kt | 24 +++++++++++++++ .../api/rest/SettingsControllerTest.kt | 29 ++++++++++++------- 12 files changed, 85 insertions(+), 22 deletions(-) create mode 100644 komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailSize.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ThumbnailSizeDto.kt diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailSize.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailSize.kt new file mode 100644 index 0000000000..293ed3762b --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailSize.kt @@ -0,0 +1,8 @@ +package org.gotson.komga.domain.model + +enum class ThumbnailSize(val maxEdge: Int) { + DEFAULT(300), + MEDIUM(600), + LARGE(900), + XLARGE(1200), +} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookAnalyzer.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookAnalyzer.kt index 7bc921033e..6943cee11b 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookAnalyzer.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookAnalyzer.kt @@ -10,6 +10,7 @@ import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.MediaNotReadyException import org.gotson.komga.domain.model.MediaUnsupportedException import org.gotson.komga.domain.model.ThumbnailBook +import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider import org.gotson.komga.infrastructure.hash.Hasher import org.gotson.komga.infrastructure.image.ImageAnalyzer import org.gotson.komga.infrastructure.image.ImageConverter @@ -35,14 +36,14 @@ class BookAnalyzer( private val imageAnalyzer: ImageAnalyzer, private val hasher: Hasher, @Value("#{@komgaProperties.pageHashing}") private val pageHashing: Int, + private val komgaSettingsProvider: KomgaSettingsProvider, ) { val supportedMediaTypes = extractors .flatMap { e -> e.mediaTypes().map { it to e } } .toMap() - private val thumbnailSize = 300 - private val thumbnailFormat = "jpeg" + val thumbnailType = ImageType.JPEG fun analyze(book: Book, analyzeDimensions: Boolean): Media { logger.info { "Trying to analyze book: $book" } @@ -122,7 +123,7 @@ class BookAnalyzer( if (coverBytes == null) coverBytes = extractor.getEntryStream(book.book.path, book.media.pages.first().fileName) coverBytes.let { cover -> - imageConverter.resizeImage(cover, thumbnailFormat, thumbnailSize) + imageConverter.resizeImage(cover, thumbnailType, komgaSettingsProvider.thumbnailSize.maxEdge) } } catch (ex: Exception) { logger.warn(ex) { "Could not generate thumbnail for book: $book" } @@ -133,7 +134,7 @@ class BookAnalyzer( thumbnail = thumbnail, type = ThumbnailBook.Type.GENERATED, bookId = book.book.id, - mediaType = "image/$thumbnailFormat", + mediaType = thumbnailType.mediaType, dimension = thumbnail?.let { imageAnalyzer.getDimension(it.inputStream()) } ?: Dimension(0, 0), fileSize = thumbnail?.size?.toLong() ?: 0, ) diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt index 0ec4ee0d56..1841fbfbc1 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt @@ -183,7 +183,7 @@ class BookLifecycle( if (resizeTo != null) { return try { - imageConverter.resizeImage(thumbnailBytes, resizeTargetFormat.imageIOFormat, resizeTo) + imageConverter.resizeImage(thumbnailBytes, resizeTargetFormat, resizeTo) } catch (e: Exception) { logger.error(e) { "Resize thumbnail of book $bookId to $resizeTo: failed" } thumbnailBytes @@ -244,7 +244,7 @@ class BookLifecycle( if (resizeTo != null) { val convertedPage = try { - imageConverter.resizeImage(pageContent, resizeTargetFormat.imageIOFormat, resizeTo) + imageConverter.resizeImage(pageContent, resizeTargetFormat, resizeTo) } catch (e: Exception) { logger.error(e) { "Resize page #$number of book $book to $resizeTo: failed" } throw e diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/KomgaSettingsProvider.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/KomgaSettingsProvider.kt index 98181e8454..dd6377082b 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/KomgaSettingsProvider.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/KomgaSettingsProvider.kt @@ -1,6 +1,7 @@ package org.gotson.komga.infrastructure.configuration import org.apache.commons.lang3.RandomStringUtils +import org.gotson.komga.domain.model.ThumbnailSize import org.gotson.komga.infrastructure.jooq.ServerSettingsDao import org.springframework.stereotype.Service import kotlin.time.Duration @@ -44,6 +45,15 @@ class KomgaSettingsProvider( serverSettingsDao.saveSetting(Settings.REMEMBER_ME_DURATION.name, value.inWholeDays.toInt()) field = value } + + var thumbnailSize: ThumbnailSize = + serverSettingsDao.getSettingByKey(Settings.THUMBNAIL_SIZE.name, String::class.java)?.let { + ThumbnailSize.valueOf(it) + } ?: ThumbnailSize.DEFAULT + set(value) { + serverSettingsDao.saveSetting(Settings.THUMBNAIL_SIZE.name, value.name) + field = value + } } private enum class Settings { @@ -51,4 +61,5 @@ private enum class Settings { DELETE_EMPTY_READLISTS, REMEMBER_ME_KEY, REMEMBER_ME_DURATION, + THUMBNAIL_SIZE, } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/ImageConverter.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/ImageConverter.kt index 438131908b..b0196ce119 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/ImageConverter.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/ImageConverter.kt @@ -7,6 +7,7 @@ import java.awt.Color import java.awt.image.BufferedImage import java.io.ByteArrayOutputStream import javax.imageio.ImageIO +import kotlin.math.min private val logger = KotlinLogging.logger {} @@ -49,15 +50,16 @@ class ImageConverter { baos.toByteArray() } - fun resizeImage(imageBytes: ByteArray, format: String, size: Int): ByteArray = - ByteArrayOutputStream().use { + fun resizeImage(imageBytes: ByteArray, format: ImageType, size: Int): ByteArray { + return ByteArrayOutputStream().use { Thumbnails.of(imageBytes.inputStream()) .size(size, size) .imageType(BufferedImage.TYPE_INT_ARGB) - .outputFormat(format) + .outputFormat(format.imageIOFormat) .toOutputStream(it) it.toByteArray() } + } private fun containsAlphaChannel(image: BufferedImage): Boolean = image.colorModel.hasAlpha() diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/ImageType.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/ImageType.kt index 3abecf8a0f..88cc8485e0 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/ImageType.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/ImageType.kt @@ -1,6 +1,6 @@ package org.gotson.komga.infrastructure.image enum class ImageType(val mediaType: String, val imageIOFormat: String) { - PNG("image/png", "png"), - JPEG("image/jpeg", "jpeg"), + PNG("image/png", "PNG"), + JPEG("image/jpeg", "JPEG"), } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/OpdsController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/OpdsController.kt index a1fb9fc257..ce99f61738 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/OpdsController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/OpdsController.kt @@ -22,6 +22,7 @@ import org.gotson.komga.domain.persistence.ReferentialRepository import org.gotson.komga.domain.persistence.SeriesCollectionRepository import org.gotson.komga.domain.persistence.SeriesMetadataRepository import org.gotson.komga.domain.service.BookLifecycle +import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider import org.gotson.komga.infrastructure.jooq.toCurrentTimeZone import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.swagger.PageAsQueryParam @@ -107,6 +108,7 @@ class OpdsController( private val referentialRepository: ReferentialRepository, private val bookRepository: BookRepository, private val bookLifecycle: BookLifecycle, + private val komgaSettingsProvider: KomgaSettingsProvider, ) { private val komgaAuthor = OpdsAuthor("Komga", URI("https://github.com/gotson/komga")) @@ -663,7 +665,7 @@ class OpdsController( principal.user.checkContentRestriction(bookId, bookRepository, seriesMetadataRepository) val thumbnail = bookLifecycle.getThumbnail(bookId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) - return bookLifecycle.getThumbnailBytes(bookId, if (thumbnail.type == ThumbnailBook.Type.GENERATED) null else 300) + return bookLifecycle.getThumbnailBytes(bookId, if (thumbnail.type == ThumbnailBook.Type.GENERATED) null else komgaSettingsProvider.thumbnailSize.maxEdge) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SettingsController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SettingsController.kt index 7d5970177d..764bbe274e 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SettingsController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SettingsController.kt @@ -5,6 +5,8 @@ import org.gotson.komga.domain.model.ROLE_ADMIN import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider import org.gotson.komga.interfaces.api.rest.dto.SettingsDto import org.gotson.komga.interfaces.api.rest.dto.SettingsUpdateDto +import org.gotson.komga.interfaces.api.rest.dto.toDomain +import org.gotson.komga.interfaces.api.rest.dto.toDto import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.security.access.prepost.PreAuthorize @@ -29,6 +31,7 @@ class SettingsController( komgaSettingsProvider.deleteEmptyCollections, komgaSettingsProvider.deleteEmptyReadLists, komgaSettingsProvider.rememberMeDuration.inWholeDays, + komgaSettingsProvider.thumbnailSize.toDto(), ) @PatchMapping @@ -41,5 +44,6 @@ class SettingsController( newSettings.deleteEmptyReadLists?.let { komgaSettingsProvider.deleteEmptyReadLists = it } newSettings.rememberMeDurationDays?.let { komgaSettingsProvider.rememberMeDuration = it.days } if (newSettings.renewRememberMeKey == true) komgaSettingsProvider.renewRememberMeKey() + newSettings.thumbnailSize?.let { komgaSettingsProvider.thumbnailSize = it.toDomain() } } } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SettingsDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SettingsDto.kt index f8d78c3e04..daddd1d815 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SettingsDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SettingsDto.kt @@ -4,4 +4,5 @@ data class SettingsDto( val deleteEmptyCollections: Boolean, val deleteEmptyReadLists: Boolean, val rememberMeDurationDays: Long, + val thumbnailSize: ThumbnailSizeDto, ) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SettingsUpdateDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SettingsUpdateDto.kt index a8203673fb..cb81d2dd36 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SettingsUpdateDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SettingsUpdateDto.kt @@ -8,4 +8,5 @@ data class SettingsUpdateDto( @get:Positive val rememberMeDurationDays: Long? = null, val renewRememberMeKey: Boolean? = null, + val thumbnailSize: ThumbnailSizeDto? = null, ) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ThumbnailSizeDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ThumbnailSizeDto.kt new file mode 100644 index 0000000000..7d63f0f00b --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ThumbnailSizeDto.kt @@ -0,0 +1,24 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import org.gotson.komga.domain.model.ThumbnailSize + +enum class ThumbnailSizeDto { + DEFAULT, + MEDIUM, + LARGE, + XLARGE, +} + +fun ThumbnailSize.toDto() = when (this) { + ThumbnailSize.DEFAULT -> ThumbnailSizeDto.DEFAULT + ThumbnailSize.MEDIUM -> ThumbnailSizeDto.MEDIUM + ThumbnailSize.LARGE -> ThumbnailSizeDto.LARGE + ThumbnailSize.XLARGE -> ThumbnailSizeDto.XLARGE +} + +fun ThumbnailSizeDto.toDomain() = when (this) { + ThumbnailSizeDto.DEFAULT -> ThumbnailSize.DEFAULT + ThumbnailSizeDto.MEDIUM -> ThumbnailSize.MEDIUM + ThumbnailSizeDto.LARGE -> ThumbnailSize.LARGE + ThumbnailSizeDto.XLARGE -> ThumbnailSize.XLARGE +} diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/SettingsControllerTest.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/SettingsControllerTest.kt index 80da7df6c3..f2d916ad3d 100644 --- a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/SettingsControllerTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/SettingsControllerTest.kt @@ -3,9 +3,12 @@ package org.gotson.komga.interfaces.api.rest import org.assertj.core.api.Assertions.assertThat import org.gotson.komga.domain.model.ROLE_ADMIN import org.gotson.komga.domain.model.ROLE_USER +import org.gotson.komga.domain.model.ThumbnailSize import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest @@ -49,6 +52,7 @@ class SettingsControllerTest( komgaSettingsProvider.deleteEmptyCollections = true komgaSettingsProvider.deleteEmptyReadLists = false komgaSettingsProvider.rememberMeDuration = 5.days + komgaSettingsProvider.thumbnailSize = ThumbnailSize.LARGE mockMvc.get("/api/v1/settings") .andExpect { @@ -56,6 +60,7 @@ class SettingsControllerTest( jsonPath("deleteEmptyCollections") { value(true) } jsonPath("deleteEmptyReadLists") { value(false) } jsonPath("rememberMeDurationDays") { value(5) } + jsonPath("thumbnailSize") { value("LARGE") } } } @@ -65,6 +70,7 @@ class SettingsControllerTest( komgaSettingsProvider.deleteEmptyCollections = true komgaSettingsProvider.deleteEmptyReadLists = true komgaSettingsProvider.rememberMeDuration = 5.days + komgaSettingsProvider.thumbnailSize = ThumbnailSize.LARGE val rememberMeKey = komgaSettingsProvider.rememberMeKey @@ -73,7 +79,8 @@ class SettingsControllerTest( { "deleteEmptyCollections": false, "rememberMeDurationDays": 15, - "renewRememberMeKey": true + "renewRememberMeKey": true, + "thumbnailSize": "MEDIUM" } """.trimIndent() @@ -89,18 +96,20 @@ class SettingsControllerTest( assertThat(komgaSettingsProvider.deleteEmptyReadLists).isTrue assertThat(komgaSettingsProvider.rememberMeDuration).isEqualTo(15.days) assertThat(komgaSettingsProvider.rememberMeKey).isNotEqualTo(rememberMeKey) + assertThat(komgaSettingsProvider.thumbnailSize).isEqualTo(ThumbnailSize.MEDIUM) } - @Test + @ParameterizedTest @WithMockCustomUser(roles = [ROLE_ADMIN]) - fun `given admin user when updating with invalid settings then returns bad request`() { - //language=JSON - val jsonString = """ - { - "rememberMeDurationDays": 0 - } - """.trimIndent() - + @ValueSource( + strings = [ + //language=JSON + """{"rememberMeDurationDays": 0}""", + //language=JSON + """{"thumbnailSize": "HUGE"}""", + ], + ) + fun `given admin user when updating with invalid settings then returns bad request`(jsonString: String) { mockMvc.patch("/api/v1/settings") { contentType = MediaType.APPLICATION_JSON content = jsonString