diff --git a/komga/src/flyway/resources/db/migration/sqlite/V20231115163730__analyze_pdf.sql b/komga/src/flyway/resources/db/migration/sqlite/V20231115163730__analyze_pdf.sql new file mode 100644 index 00000000..50f7a8de --- /dev/null +++ b/komga/src/flyway/resources/db/migration/sqlite/V20231115163730__analyze_pdf.sql @@ -0,0 +1,4 @@ +update media +set STATUS = 'OUTDATED' +where MEDIA_TYPE = 'application/pdf' + and STATUS = 'READY'; diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/Media.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/Media.kt index f124db32..029763df 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/Media.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/Media.kt @@ -14,6 +14,9 @@ data class Media( override val lastModifiedDate: LocalDateTime = createdDate, ) : Auditable { + @delegate:Transient + val profile: MediaProfile? by lazy { MediaType.fromMediaType(mediaType)?.profile } + enum class Status { UNKNOWN, ERROR, READY, UNSUPPORTED, OUTDATED } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaProfile.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaProfile.kt new file mode 100644 index 00000000..e6114969 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaProfile.kt @@ -0,0 +1,6 @@ +package org.gotson.komga.domain.model + +enum class MediaProfile { + DIVINA, + PDF, +} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaType.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaType.kt index 03041d7b..374f430e 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaType.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaType.kt @@ -1,11 +1,11 @@ package org.gotson.komga.domain.model -enum class MediaType(val type: String, val fileExtension: String, val exportType: String = type) { - ZIP("application/zip", "cbz", "application/vnd.comicbook+zip"), - RAR_GENERIC("application/x-rar-compressed", "cbr", "application/vnd.comicbook-rar"), - RAR_4("application/x-rar-compressed; version=4", "cbr", "application/vnd.comicbook-rar"), - EPUB("application/epub+zip", "epub"), - PDF("application/pdf", "pdf"), +enum class MediaType(val type: String, val profile: MediaProfile, val fileExtension: String, val exportType: String = type) { + ZIP("application/zip", MediaProfile.DIVINA, "cbz", "application/vnd.comicbook+zip"), + RAR_GENERIC("application/x-rar-compressed", MediaProfile.DIVINA, "cbr", "application/vnd.comicbook-rar"), + RAR_4("application/x-rar-compressed; version=4", MediaProfile.DIVINA, "cbr", "application/vnd.comicbook-rar"), + EPUB("application/epub+zip", MediaProfile.DIVINA, "epub"), + PDF("application/pdf", MediaProfile.PDF, "pdf"), ; companion object { 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 0715391c..e3307b8d 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 @@ -8,6 +8,8 @@ import org.gotson.komga.domain.model.BookWithMedia import org.gotson.komga.domain.model.Dimension import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.MediaNotReadyException +import org.gotson.komga.domain.model.MediaProfile +import org.gotson.komga.domain.model.MediaType import org.gotson.komga.domain.model.MediaUnsupportedException import org.gotson.komga.domain.model.ThumbnailBook import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider @@ -18,7 +20,8 @@ import org.gotson.komga.infrastructure.image.ImageType import org.gotson.komga.infrastructure.mediacontainer.ContentDetector import org.gotson.komga.infrastructure.mediacontainer.CoverExtractor import org.gotson.komga.infrastructure.mediacontainer.MediaContainerExtractor -import org.gotson.komga.infrastructure.mediacontainer.MediaContainerRawExtractor +import org.gotson.komga.infrastructure.mediacontainer.PdfExtractor +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import java.io.ByteArrayOutputStream @@ -32,32 +35,42 @@ private val logger = KotlinLogging.logger {} class BookAnalyzer( private val contentDetector: ContentDetector, extractors: List, + private val pdfExtractor: PdfExtractor, private val imageConverter: ImageConverter, private val imageAnalyzer: ImageAnalyzer, private val hasher: Hasher, @Value("#{@komgaProperties.pageHashing}") private val pageHashing: Int, private val komgaSettingsProvider: KomgaSettingsProvider, + @Qualifier("thumbnailType") private val thumbnailType: ImageType, + @Qualifier("pdfImageType") + private val pdfImageType: ImageType, ) { val supportedMediaTypes = extractors .flatMap { e -> e.mediaTypes().map { it to e } } .toMap() + fun analyze(book: Book, analyzeDimensions: Boolean): Media { logger.info { "Trying to analyze book: $book" } try { - val mediaType = contentDetector.detectMediaType(book.path) - logger.info { "Detected media type: $mediaType" } - if (!supportedMediaTypes.containsKey(mediaType)) - return Media(mediaType = mediaType, status = Media.Status.UNSUPPORTED, comment = "ERR_1001", bookId = book.id) + val mediaType = contentDetector.detectMediaType(book.path).let { + logger.info { "Detected media type: $it" } + MediaType.fromMediaType(it) ?: return Media(mediaType = it, status = Media.Status.UNSUPPORTED, comment = "ERR_1001", bookId = book.id) + } + + if (mediaType.profile == MediaProfile.PDF) { + val pages = pdfExtractor.getPages(book.path, analyzeDimensions).map { BookPage(it.name, "", it.dimension) } + return Media(mediaType = mediaType.type, status = Media.Status.READY, pages = pages, bookId = book.id) + } val entries = try { - supportedMediaTypes.getValue(mediaType).getEntries(book.path, analyzeDimensions) + supportedMediaTypes.getValue(mediaType.type).getEntries(book.path, analyzeDimensions) } catch (ex: MediaUnsupportedException) { - return Media(mediaType = mediaType, status = Media.Status.UNSUPPORTED, comment = ex.code, bookId = book.id) + return Media(mediaType = mediaType.type, status = Media.Status.UNSUPPORTED, comment = ex.code, bookId = book.id) } catch (ex: Exception) { logger.error(ex) { "Error while analyzing book: $book" } - return Media(mediaType = mediaType, status = Media.Status.ERROR, comment = "ERR_1008", bookId = book.id) + return Media(mediaType = mediaType.type, status = Media.Status.ERROR, comment = "ERR_1008", bookId = book.id) } val (pages, others) = entries @@ -78,13 +91,13 @@ class BookAnalyzer( if (pages.isEmpty()) { logger.warn { "Book $book does not contain any pages" } - return Media(mediaType = mediaType, status = Media.Status.ERROR, comment = "ERR_1006", bookId = book.id) + return Media(mediaType = mediaType.type, status = Media.Status.ERROR, comment = "ERR_1006", bookId = book.id) } logger.info { "Book has ${pages.size} pages" } val files = others.map { it.name } - return Media(mediaType = mediaType, status = Media.Status.READY, pages = pages, pageCount = pages.size, files = files, comment = entriesErrorSummary, bookId = book.id) + return Media(mediaType = mediaType.type, status = Media.Status.READY, pages = pages, pageCount = pages.size, files = files, comment = entriesErrorSummary, bookId = book.id) } catch (ade: AccessDeniedException) { logger.error(ade) { "Error while analyzing book: $book" } return Media(status = Media.Status.ERROR, comment = "ERR_1000", bookId = book.id) @@ -107,7 +120,7 @@ class BookAnalyzer( } val thumbnail = try { - val extractor = supportedMediaTypes.getValue(book.media.mediaType!!) + val extractor = supportedMediaTypes[book.media.mediaType!!] // try to get the cover from a CoverExtractor first var coverBytes: ByteArray? = if (extractor is CoverExtractor) { try { @@ -118,9 +131,12 @@ class BookAnalyzer( } } else null // if no cover could be found, get the first page - if (coverBytes == null) coverBytes = extractor.getEntryStream(book.book.path, book.media.pages.first().fileName) + if (coverBytes == null) { + coverBytes = if (book.media.profile == MediaProfile.PDF) pdfExtractor.getPageContentAsImage(book.book.path, 1).content + else extractor?.getEntryStream(book.book.path, book.media.pages.first().fileName) + } - coverBytes.let { cover -> + coverBytes?.let { cover -> imageConverter.resizeImageToByteArray(cover, thumbnailType, komgaSettingsProvider.thumbnailSize.maxEdge) } } catch (ex: Exception) { @@ -155,7 +171,11 @@ class BookAnalyzer( throw IndexOutOfBoundsException("Page $number does not exist") } - return supportedMediaTypes.getValue(book.media.mediaType!!).getEntryStream(book.book.path, book.media.pages[number - 1].fileName) + return when (book.media.profile) { + MediaProfile.DIVINA -> supportedMediaTypes.getValue(book.media.mediaType!!).getEntryStream(book.book.path, book.media.pages[number - 1].fileName) + MediaProfile.PDF -> pdfExtractor.getPageContentAsImage(book.book.path, number).content + null -> throw MediaNotReadyException() + } } @Throws( @@ -175,10 +195,9 @@ class BookAnalyzer( throw IndexOutOfBoundsException("Page $number does not exist") } - val extractor = supportedMediaTypes.getValue(book.media.mediaType!!) - if (extractor !is MediaContainerRawExtractor) throw MediaUnsupportedException("Extractor does not support raw extraction of pages") + if (book.media.profile != MediaProfile.PDF) throw MediaUnsupportedException("Extractor does not support raw extraction of pages") - return extractor.getRawEntryStream(book.book.path, book.media.pages[number - 1].fileName) + return pdfExtractor.getPageContentAsPdf(book.book.path, number) } @Throws( @@ -192,6 +211,8 @@ class BookAnalyzer( throw MediaNotReadyException() } + if (book.media.profile != MediaProfile.DIVINA) throw MediaUnsupportedException("Extractor does not support extraction of files") + return supportedMediaTypes.getValue(book.media.mediaType!!).getEntryStream(book.book.path, fileName) } @@ -230,4 +251,15 @@ class BookAnalyzer( return hasher.computeHash(bytes.inputStream()) } + + fun getPdfPagesDynamic(media: Media): List { + if (media.profile != MediaProfile.PDF) throw MediaUnsupportedException("Cannot get synthetic pages for non-PDF media") + + return media.pages.map { page -> + page.copy( + mediaType = pdfImageType.mediaType, + dimension = page.dimension?.let { pdfExtractor.scaleDimension(it) }, + ) + } + } } 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 55dde7f5..87b8033d 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 @@ -13,6 +13,7 @@ import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.MarkSelectedPreference import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.MediaNotReadyException +import org.gotson.komga.domain.model.MediaProfile import org.gotson.komga.domain.model.ReadProgress import org.gotson.komga.domain.model.ThumbnailBook import org.gotson.komga.domain.persistence.BookMetadataRepository @@ -27,6 +28,7 @@ import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider import org.gotson.komga.infrastructure.hash.Hasher import org.gotson.komga.infrastructure.image.ImageConverter import org.gotson.komga.infrastructure.image.ImageType +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.context.ApplicationEventPublisher import org.springframework.data.domain.Sort import org.springframework.stereotype.Service @@ -58,6 +60,8 @@ class BookLifecycle( private val hasher: Hasher, private val historicalEventRepository: HistoricalEventRepository, private val komgaSettingsProvider: KomgaSettingsProvider, + @Qualifier("pdfImageType") + private val pdfImageType: ImageType, ) { private val resizeTargetFormat = ImageType.JPEG @@ -252,7 +256,9 @@ class BookLifecycle( fun getBookPage(book: Book, number: Int, convertTo: ImageType? = null, resizeTo: Int? = null): BookPageContent { val media = mediaRepository.findById(book.id) val pageContent = bookAnalyzer.getPageContent(BookWithMedia(book, media), number) - val pageMediaType = media.pages[number - 1].mediaType + val pageMediaType = + if (media.profile == MediaProfile.PDF) pdfImageType.mediaType + else media.pages[number - 1].mediaType if (resizeTo != null) { val convertedPage = try { diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/ThumbnailConfiguration.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/ThumbnailConfiguration.kt deleted file mode 100644 index 00e03ade..00000000 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/ThumbnailConfiguration.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.gotson.komga.domain.service - -import org.gotson.komga.infrastructure.image.ImageType -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration - -@Configuration -class ThumbnailConfiguration { - @Bean - fun thumbnailType() = ImageType.JPEG -} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/TransientBookLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/TransientBookLifecycle.kt index 496cf613..fe6dbcac 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/TransientBookLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/TransientBookLifecycle.kt @@ -4,9 +4,12 @@ import org.gotson.komga.domain.model.BookPageContent import org.gotson.komga.domain.model.BookWithMedia import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.MediaNotReadyException +import org.gotson.komga.domain.model.MediaProfile import org.gotson.komga.domain.model.PathContainedInPath import org.gotson.komga.domain.persistence.LibraryRepository import org.gotson.komga.domain.persistence.TransientBookRepository +import org.gotson.komga.infrastructure.image.ImageType +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.stereotype.Service import java.nio.file.Paths @@ -16,6 +19,8 @@ class TransientBookLifecycle( private val bookAnalyzer: BookAnalyzer, private val fileSystemScanner: FileSystemScanner, private val libraryRepository: LibraryRepository, + @Qualifier("pdfImageType") + private val pdfImageType: ImageType, ) { fun scanAndPersist(filePath: String): List { @@ -47,7 +52,9 @@ class TransientBookLifecycle( ) fun getBookPage(transientBook: BookWithMedia, number: Int): BookPageContent { val pageContent = bookAnalyzer.getPageContent(transientBook, number) - val pageMediaType = transientBook.media.pages[number - 1].mediaType + val pageMediaType = + if (transientBook.media.profile == MediaProfile.PDF) pdfImageType.mediaType + else transientBook.media.pages[number - 1].mediaType return BookPageContent(pageContent, pageMediaType) } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/StaticConfiguration.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/StaticConfiguration.kt new file mode 100644 index 00000000..80c65913 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/StaticConfiguration.kt @@ -0,0 +1,17 @@ +package org.gotson.komga.infrastructure.configuration + +import org.gotson.komga.infrastructure.image.ImageType +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class StaticConfiguration { + @Bean("thumbnailType") + fun thumbnailType() = ImageType.JPEG + + @Bean("pdfImageType") + fun pdfImageType() = ImageType.JPEG + + @Bean("pdfResolution") + fun pdfResolution(): Float = 1536F +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/MosaicGenerator.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/MosaicGenerator.kt index dd08dcc1..3a612c29 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/MosaicGenerator.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/MosaicGenerator.kt @@ -1,6 +1,7 @@ package org.gotson.komga.infrastructure.image import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.stereotype.Service import java.awt.image.BufferedImage import java.io.ByteArrayOutputStream @@ -10,6 +11,7 @@ import kotlin.math.roundToInt @Service class MosaicGenerator( private val komgaSettingsProvider: KomgaSettingsProvider, + @Qualifier("thumbnailType") private val thumbnailType: ImageType, private val imageConverter: ImageConverter, ) { diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/MediaContainerRawExtractor.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/MediaContainerRawExtractor.kt deleted file mode 100644 index d774a1c1..00000000 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/MediaContainerRawExtractor.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.gotson.komga.infrastructure.mediacontainer - -import org.gotson.komga.domain.model.BookPageContent -import java.nio.file.Path - -interface MediaContainerRawExtractor : MediaContainerExtractor { - fun getRawEntryStream(path: Path, entryName: String): BookPageContent -} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/PdfExtractor.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/PdfExtractor.kt index e72d6e6c..03e224d3 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/PdfExtractor.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/PdfExtractor.kt @@ -5,12 +5,14 @@ import org.apache.pdfbox.io.MemoryUsageSetting import org.apache.pdfbox.multipdf.PageExtractor import org.apache.pdfbox.pdmodel.PDDocument import org.apache.pdfbox.pdmodel.PDPage -import org.apache.pdfbox.rendering.ImageType +import org.apache.pdfbox.rendering.ImageType.RGB import org.apache.pdfbox.rendering.PDFRenderer import org.gotson.komga.domain.model.BookPageContent import org.gotson.komga.domain.model.Dimension import org.gotson.komga.domain.model.MediaContainerEntry import org.gotson.komga.domain.model.MediaType +import org.gotson.komga.infrastructure.image.ImageType +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.stereotype.Service import java.io.ByteArrayOutputStream import java.nio.file.Path @@ -20,39 +22,37 @@ import kotlin.math.roundToInt private val logger = KotlinLogging.logger {} @Service -class PdfExtractor : MediaContainerRawExtractor { +class PdfExtractor( + @Qualifier("pdfImageType") + private val imageType: ImageType, + @Qualifier("pdfResolution") + private val resolution: Float, +) { + fun getPageCount(path: Path): Int = PDDocument.load(path.toFile(), MemoryUsageSetting.setupTempFileOnly()).use { pdf -> pdf.numberOfPages } - private val mediaType = "image/jpeg" - private val imageIOFormat = "jpeg" - private val resolution = 1536F - - override fun mediaTypes(): List = listOf(MediaType.PDF.type) - - override fun getEntries(path: Path, analyzeDimensions: Boolean): List = + fun getPages(path: Path, analyzeDimensions: Boolean): List = PDDocument.load(path.toFile(), MemoryUsageSetting.setupTempFileOnly()).use { pdf -> (0 until pdf.numberOfPages).map { index -> val page = pdf.getPage(index) - val scale = page.getScale() - val dimension = if (analyzeDimensions) Dimension((page.cropBox.width * scale).roundToInt(), (page.cropBox.height * scale).roundToInt()) else null - MediaContainerEntry(name = index.toString(), mediaType = mediaType, dimension = dimension) + val dimension = if (analyzeDimensions) Dimension(page.cropBox.width.roundToInt(), page.cropBox.height.roundToInt()) else null + MediaContainerEntry(name = "${index + 1}", dimension = dimension) } } - override fun getEntryStream(path: Path, entryName: String): ByteArray { + fun getPageContentAsImage(path: Path, pageNumber: Int): BookPageContent { PDDocument.load(path.toFile(), MemoryUsageSetting.setupTempFileOnly()).use { pdf -> - val pageNumber = entryName.toInt() val page = pdf.getPage(pageNumber) - val image = PDFRenderer(pdf).renderImage(pageNumber, page.getScale(), ImageType.RGB) - return ByteArrayOutputStream().use { out -> - ImageIO.write(image, imageIOFormat, out) + val image = PDFRenderer(pdf).renderImage(pageNumber - 1, page.getScale(), RGB) + val bytes = ByteArrayOutputStream().use { out -> + ImageIO.write(image, imageType.imageIOFormat, out) out.toByteArray() } + return BookPageContent(bytes, imageType.mediaType) } } - override fun getRawEntryStream(path: Path, entryName: String): BookPageContent { + fun getPageContentAsPdf(path: Path, pageNumber: Int): BookPageContent { PDDocument.load(path.toFile(), MemoryUsageSetting.setupTempFileOnly()).use { pdf -> - val pageNumber = entryName.toInt() + 1 val bytes = ByteArrayOutputStream().use { out -> PageExtractor(pdf, pageNumber, pageNumber).extract().save(out) out.toByteArray() @@ -61,5 +61,12 @@ class PdfExtractor : MediaContainerRawExtractor { } } - private fun PDPage.getScale() = resolution / minOf(cropBox.width, cropBox.height) + private fun PDPage.getScale() = getScale(cropBox.width, cropBox.height) + + private fun getScale(width: Float, height: Float) = resolution / minOf(width, height) + + fun scaleDimension(dimension: Dimension): Dimension { + val scale = getScale(dimension.width.toFloat(), dimension.height.toFloat()) + return Dimension((dimension.width * scale).roundToInt(), (dimension.height * scale).roundToInt()) + } } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/WebPubGenerator.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/WebPubGenerator.kt new file mode 100644 index 00000000..7af39567 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/WebPubGenerator.kt @@ -0,0 +1,201 @@ +package org.gotson.komga.interfaces.api + +import org.gotson.komga.domain.model.BookPage +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaProfile +import org.gotson.komga.domain.model.SeriesMetadata +import org.gotson.komga.domain.service.BookAnalyzer +import org.gotson.komga.infrastructure.image.ImageConverter +import org.gotson.komga.infrastructure.image.ImageType +import org.gotson.komga.infrastructure.jooq.toCurrentTimeZone +import org.gotson.komga.interfaces.api.dto.MEDIATYPE_DIVINA_JSON +import org.gotson.komga.interfaces.api.dto.MEDIATYPE_DIVINA_JSON_VALUE +import org.gotson.komga.interfaces.api.dto.MEDIATYPE_OPDS_JSON_VALUE +import org.gotson.komga.interfaces.api.dto.MEDIATYPE_OPDS_PUBLICATION_JSON +import org.gotson.komga.interfaces.api.dto.MEDIATYPE_WEBPUB_JSON +import org.gotson.komga.interfaces.api.dto.MEDIATYPE_WEBPUB_JSON_VALUE +import org.gotson.komga.interfaces.api.dto.OpdsLinkRel +import org.gotson.komga.interfaces.api.dto.PROFILE_DIVINA +import org.gotson.komga.interfaces.api.dto.PROFILE_PDF +import org.gotson.komga.interfaces.api.dto.WPBelongsToDto +import org.gotson.komga.interfaces.api.dto.WPContributorDto +import org.gotson.komga.interfaces.api.dto.WPLinkDto +import org.gotson.komga.interfaces.api.dto.WPMetadataDto +import org.gotson.komga.interfaces.api.dto.WPPublicationDto +import org.gotson.komga.interfaces.api.dto.WPReadingProgressionDto +import org.gotson.komga.interfaces.api.rest.dto.AuthorDto +import org.gotson.komga.interfaces.api.rest.dto.BookDto +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.http.MediaType +import org.springframework.stereotype.Service +import org.springframework.web.servlet.support.ServletUriComponentsBuilder +import org.springframework.web.util.UriComponentsBuilder +import java.time.ZoneId +import java.time.ZonedDateTime +import org.gotson.komga.domain.model.MediaType as KomgaMediaType + +@Service +class WebPubGenerator( + @Qualifier("pdfImageType") + private val pdfImageType: ImageType, + @Qualifier("thumbnailType") + private val thumbnailType: ImageType, + private val imageConverter: ImageConverter, + private val bookAnalyzer: BookAnalyzer, +) { + private val wpKnownRoles = listOf( + "author", + "translator", + "editor", + "artist", + "illustrator", + "letterer", + "penciler", + "penciller", + "colorist", + "inker", + ) + + private val recommendedImageMediaTypes = listOf("image/jpeg", "image/png", "image/gif") + + private fun BookDto.toBasePublicationDto(includeOpdsLinks: Boolean = false): WPPublicationDto { + val uriBuilder = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("api", "v1") + return WPPublicationDto( + mediaType = MEDIATYPE_OPDS_PUBLICATION_JSON, + context = "https://readium.org/webpub-manifest/context.jsonld", + metadata = toWPMetadataDto(includeOpdsLinks).withAuthors(metadata.authors), + links = toWPLinkDtos(uriBuilder), + ) + } + + fun toOpdsPublicationDto(bookDto: BookDto, includeOpdsLinks: Boolean = false): WPPublicationDto { + return bookDto.toBasePublicationDto(includeOpdsLinks).copy(images = buildThumbnailLinkDtos(bookDto.id)) + } + + private fun buildThumbnailLinkDtos(bookId: String) = listOf( + WPLinkDto( + href = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("api", "v1").path("books/$bookId/thumbnail").toUriString(), + type = thumbnailType.mediaType, + ), + ) + + fun toManifestDivina(bookDto: BookDto, media: Media, seriesMetadata: SeriesMetadata): WPPublicationDto { + val uriBuilder = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("api", "v1") + return bookDto.toBasePublicationDto().let { + val pages = if (media.profile == MediaProfile.PDF) bookAnalyzer.getPdfPagesDynamic(media) else media.pages + it.copy( + mediaType = MEDIATYPE_DIVINA_JSON, + metadata = it.metadata + .withSeriesMetadata(seriesMetadata) + .copy(conformsTo = PROFILE_DIVINA), + readingOrder = pages.mapIndexed { index: Int, page: BookPage -> + WPLinkDto( + href = uriBuilder.cloneBuilder().path("books/${bookDto.id}/pages/${index + 1}").toUriString(), + type = page.mediaType, + width = page.dimension?.width, + height = page.dimension?.height, + alternate = if (!recommendedImageMediaTypes.contains(page.mediaType) && imageConverter.canConvertMediaType(page.mediaType, MediaType.IMAGE_JPEG_VALUE)) listOf( + WPLinkDto( + href = uriBuilder.cloneBuilder().path("books/${bookDto.id}/pages/${index + 1}").queryParam("convert", "jpeg").toUriString(), + type = MediaType.IMAGE_JPEG_VALUE, + width = page.dimension?.width, + height = page.dimension?.height, + ), + ) else emptyList(), + ) + }, + resources = buildThumbnailLinkDtos(bookDto.id), + ) + } + } + + fun toManifestPdf(bookDto: BookDto, media: Media, seriesMetadata: SeriesMetadata): WPPublicationDto { + val uriBuilder = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("api", "v1") + return bookDto.toBasePublicationDto().let { + it.copy( + mediaType = MEDIATYPE_WEBPUB_JSON, + metadata = it.metadata + .withSeriesMetadata(seriesMetadata) + .copy(conformsTo = PROFILE_PDF), + readingOrder = List(media.pageCount) { index: Int -> + WPLinkDto( + href = uriBuilder.cloneBuilder().path("books/${bookDto.id}/pages/${index + 1}/raw").toUriString(), + type = KomgaMediaType.PDF.type, + ) + }, + resources = buildThumbnailLinkDtos(bookDto.id), + ) + } + } + + private fun BookDto.toWPMetadataDto(includeOpdsLinks: Boolean = false) = WPMetadataDto( + title = metadata.title, + description = metadata.summary, + numberOfPages = this.media.pagesCount, + modified = lastModified.toCurrentTimeZone().atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), + published = metadata.releaseDate, + subject = metadata.tags.toList(), + identifier = if (metadata.isbn.isNotBlank()) "urn:isbn:${metadata.isbn}" else null, + belongsTo = WPBelongsToDto( + series = listOf( + WPContributorDto( + seriesTitle, + metadata.numberSort, + if (includeOpdsLinks) listOf( + WPLinkDto( + href = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("opds", "v2").path("series/$seriesId").toUriString(), + type = MEDIATYPE_OPDS_JSON_VALUE, + ), + ) else emptyList(), + ), + ), + ), + ) + + private fun WPMetadataDto.withSeriesMetadata(seriesMetadata: SeriesMetadata) = + copy( + language = seriesMetadata.language, + readingProgression = when (seriesMetadata.readingDirection) { + SeriesMetadata.ReadingDirection.LEFT_TO_RIGHT -> WPReadingProgressionDto.LTR + SeriesMetadata.ReadingDirection.RIGHT_TO_LEFT -> WPReadingProgressionDto.RTL + SeriesMetadata.ReadingDirection.VERTICAL -> WPReadingProgressionDto.TTB + SeriesMetadata.ReadingDirection.WEBTOON -> WPReadingProgressionDto.TTB + null -> null + }, + ) + + private fun WPMetadataDto.withAuthors(authors: List): WPMetadataDto { + val groups = authors.groupBy({ it.role }, { it.name }) + return copy( + author = groups["author"].orEmpty(), + translator = groups["translator"].orEmpty(), + editor = groups["editor"].orEmpty(), + artist = groups["artist"].orEmpty(), + illustrator = groups["illustrator"].orEmpty(), + letterer = groups["letterer"].orEmpty(), + penciler = groups["penciler"].orEmpty() + groups["penciller"].orEmpty(), + colorist = groups["colorist"].orEmpty(), + inker = groups["inker"].orEmpty(), + // use contributor role for all roles not mentioned above + contributor = authors.filterNot { wpKnownRoles.contains(it.role) }.map { it.name }, + ) + } + + private fun BookDto.toWPLinkDtos(uriBuilder: UriComponentsBuilder): List { + val komgaMediaType = KomgaMediaType.fromMediaType(media.mediaType) + return listOfNotNull( + // most appropriate manifest + WPLinkDto(rel = OpdsLinkRel.SELF, href = uriBuilder.cloneBuilder().path("books/$id/manifest").toUriString(), type = mediaProfileToWebPub(komgaMediaType?.profile)), + // PDF is also available under the Divina profile + if (komgaMediaType?.profile == MediaProfile.PDF) WPLinkDto(href = uriBuilder.cloneBuilder().path("books/$id/manifest/divina").toUriString(), type = MEDIATYPE_DIVINA_JSON_VALUE) else null, + // main acquisition link + WPLinkDto(rel = OpdsLinkRel.ACQUISITION, type = komgaMediaType?.exportType ?: media.mediaType, href = uriBuilder.cloneBuilder().path("books/$id/file").toUriString()), + ) + } + + private fun mediaProfileToWebPub(profile: MediaProfile?): String = when (profile) { + MediaProfile.DIVINA -> MEDIATYPE_DIVINA_JSON_VALUE + MediaProfile.PDF -> MEDIATYPE_WEBPUB_JSON_VALUE + null -> MEDIATYPE_WEBPUB_JSON_VALUE + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/dto/WepPubHelper.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/dto/WepPubHelper.kt deleted file mode 100644 index b9eba90d..00000000 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/dto/WepPubHelper.kt +++ /dev/null @@ -1,178 +0,0 @@ -package org.gotson.komga.interfaces.api.dto - -import org.gotson.komga.domain.model.BookPage -import org.gotson.komga.domain.model.Media -import org.gotson.komga.domain.model.MediaType.PDF -import org.gotson.komga.domain.model.SeriesMetadata -import org.gotson.komga.domain.model.SeriesMetadata.ReadingDirection.LEFT_TO_RIGHT -import org.gotson.komga.domain.model.SeriesMetadata.ReadingDirection.RIGHT_TO_LEFT -import org.gotson.komga.domain.model.SeriesMetadata.ReadingDirection.VERTICAL -import org.gotson.komga.domain.model.SeriesMetadata.ReadingDirection.WEBTOON -import org.gotson.komga.infrastructure.jooq.toCurrentTimeZone -import org.gotson.komga.interfaces.api.rest.dto.AuthorDto -import org.gotson.komga.interfaces.api.rest.dto.BookDto -import org.springframework.http.MediaType -import org.springframework.web.servlet.support.ServletUriComponentsBuilder -import org.springframework.web.util.UriComponentsBuilder -import java.time.ZoneId -import java.time.ZonedDateTime -import org.gotson.komga.domain.model.MediaType as KMediaType -import org.gotson.komga.domain.model.MediaType.Companion as KomgaMediaType - -val wpKnownRoles = listOf( - "author", - "translator", - "editor", - "artist", - "illustrator", - "letterer", - "penciler", - "penciller", - "colorist", - "inker", -) - -val recommendedImageMediaTypes = listOf("image/jpeg", "image/png", "image/gif") - -private fun BookDto.toBasePublicationDto(includeOpdsLinks: Boolean = false): WPPublicationDto { - val uriBuilder = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("api", "v1") - return WPPublicationDto( - mediaType = MEDIATYPE_OPDS_PUBLICATION_JSON, - context = "https://readium.org/webpub-manifest/context.jsonld", - metadata = toWPMetadataDto(includeOpdsLinks).withAuthors(metadata.authors), - links = toWPLinkDtos(uriBuilder), - ) -} - -fun BookDto.toOpdsPublicationDto(includeOpdsLinks: Boolean = false): WPPublicationDto { - return toBasePublicationDto(includeOpdsLinks).copy(images = buildThumbnailLinkDtos(id)) -} - -private fun buildThumbnailLinkDtos(bookId: String) = listOf( - WPLinkDto( - href = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("api", "v1").path("books/$bookId/thumbnail").toUriString(), - type = MediaType.IMAGE_JPEG_VALUE, - ), -) - -fun BookDto.toManifestDivina(canConvertMediaType: (String, String) -> Boolean, media: Media, seriesMetadata: SeriesMetadata): WPPublicationDto { - val uriBuilder = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("api", "v1") - return toBasePublicationDto().let { - it.copy( - mediaType = MEDIATYPE_DIVINA_JSON, - metadata = it.metadata - .withSeriesMetadata(seriesMetadata) - .copy(conformsTo = PROFILE_DIVINA), - readingOrder = media.pages.mapIndexed { index: Int, page: BookPage -> - WPLinkDto( - href = uriBuilder.cloneBuilder().path("books/$id/pages/${index + 1}").toUriString(), - type = page.mediaType, - width = page.dimension?.width, - height = page.dimension?.height, - alternate = if (!recommendedImageMediaTypes.contains(page.mediaType) && canConvertMediaType(page.mediaType, MediaType.IMAGE_JPEG_VALUE)) listOf( - WPLinkDto( - href = uriBuilder.cloneBuilder().path("books/$id/pages/${index + 1}").queryParam("convert", "jpeg").toUriString(), - type = MediaType.IMAGE_JPEG_VALUE, - width = page.dimension?.width, - height = page.dimension?.height, - ), - ) else emptyList(), - ) - }, - resources = buildThumbnailLinkDtos(id), - ) - } -} - -fun BookDto.toManifestPdf(media: Media, seriesMetadata: SeriesMetadata): WPPublicationDto { - val uriBuilder = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("api", "v1") - return toBasePublicationDto().let { - it.copy( - mediaType = MEDIATYPE_WEBPUB_JSON, - metadata = it.metadata - .withSeriesMetadata(seriesMetadata) - .copy(conformsTo = PROFILE_PDF), - readingOrder = List(media.pageCount) { index: Int -> - WPLinkDto( - href = uriBuilder.cloneBuilder().path("books/$id/pages/${index + 1}/raw").toUriString(), - type = PDF.type, - ) - }, - resources = buildThumbnailLinkDtos(id), - ) - } -} - -private fun BookDto.toWPMetadataDto(includeOpdsLinks: Boolean = false) = WPMetadataDto( - title = metadata.title, - description = metadata.summary, - numberOfPages = this.media.pagesCount, - modified = lastModified.toCurrentTimeZone().atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), - published = metadata.releaseDate, - subject = metadata.tags.toList(), - identifier = if (metadata.isbn.isNotBlank()) "urn:isbn:${metadata.isbn}" else null, - belongsTo = WPBelongsToDto( - series = listOf( - WPContributorDto( - seriesTitle, - metadata.numberSort, - if (includeOpdsLinks) listOf( - WPLinkDto( - href = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("opds", "v2").path("series/$seriesId").toUriString(), - type = MEDIATYPE_OPDS_JSON_VALUE, - ), - ) else emptyList(), - ), - ), - ), -) - -private fun WPMetadataDto.withSeriesMetadata(seriesMetadata: SeriesMetadata) = - copy( - language = seriesMetadata.language, - readingProgression = when (seriesMetadata.readingDirection) { - LEFT_TO_RIGHT -> WPReadingProgressionDto.LTR - RIGHT_TO_LEFT -> WPReadingProgressionDto.RTL - VERTICAL -> WPReadingProgressionDto.TTB - WEBTOON -> WPReadingProgressionDto.TTB - null -> null - }, - ) - -private fun WPMetadataDto.withAuthors(authors: List): WPMetadataDto { - val groups = authors.groupBy({ it.role }, { it.name }) - return copy( - author = groups["author"].orEmpty(), - translator = groups["translator"].orEmpty(), - editor = groups["editor"].orEmpty(), - artist = groups["artist"].orEmpty(), - illustrator = groups["illustrator"].orEmpty(), - letterer = groups["letterer"].orEmpty(), - penciler = groups["penciler"].orEmpty() + groups["penciller"].orEmpty(), - colorist = groups["colorist"].orEmpty(), - inker = groups["inker"].orEmpty(), - // use contributor role for all roles not mentioned above - contributor = authors.filterNot { wpKnownRoles.contains(it.role) }.map { it.name }, - ) -} - -private fun BookDto.toWPLinkDtos(uriBuilder: UriComponentsBuilder): List { - val komgaMediaType = KomgaMediaType.fromMediaType(media.mediaType) - return listOfNotNull( - // most appropriate manifest - WPLinkDto(rel = OpdsLinkRel.SELF, href = uriBuilder.cloneBuilder().path("books/$id/manifest").toUriString(), type = mediaTypeToWebPub(komgaMediaType)), - // PDF is also available under the Divina profile - if (komgaMediaType == PDF) WPLinkDto(href = uriBuilder.cloneBuilder().path("books/$id/manifest/divina").toUriString(), type = MEDIATYPE_DIVINA_JSON_VALUE) else null, - // main acquisition link - WPLinkDto(rel = OpdsLinkRel.ACQUISITION, type = komgaMediaType?.exportType ?: media.mediaType, href = uriBuilder.cloneBuilder().path("books/$id/file").toUriString()), - ) -} - -private fun mediaTypeToWebPub(mediaType: KMediaType?): String = when (mediaType) { - KMediaType.ZIP -> MEDIATYPE_DIVINA_JSON_VALUE - KMediaType.RAR_GENERIC -> MEDIATYPE_DIVINA_JSON_VALUE - KMediaType.RAR_4 -> MEDIATYPE_DIVINA_JSON_VALUE - KMediaType.EPUB -> MEDIATYPE_DIVINA_JSON_VALUE - PDF -> MEDIATYPE_WEBPUB_JSON_VALUE - null -> MEDIATYPE_WEBPUB_JSON_VALUE -} 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 873401eb..95877aaa 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 @@ -9,6 +9,7 @@ import org.apache.commons.io.FilenameUtils import org.gotson.komga.domain.model.BookSearchWithReadProgress import org.gotson.komga.domain.model.Library import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaProfile import org.gotson.komga.domain.model.ReadList import org.gotson.komga.domain.model.ReadStatus import org.gotson.komga.domain.model.SeriesCollection @@ -23,6 +24,7 @@ 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.image.ImageType import org.gotson.komga.infrastructure.jooq.toCurrentTimeZone import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.swagger.PageAsQueryParam @@ -47,6 +49,7 @@ import org.gotson.komga.interfaces.api.persistence.BookDtoRepository import org.gotson.komga.interfaces.api.persistence.SeriesDtoRepository import org.gotson.komga.interfaces.api.rest.dto.BookDto import org.gotson.komga.interfaces.api.rest.dto.SeriesDto +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.data.domain.Page import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Pageable @@ -109,6 +112,8 @@ class OpdsController( private val bookRepository: BookRepository, private val bookLifecycle: BookLifecycle, private val komgaSettingsProvider: KomgaSettingsProvider, + @Qualifier("pdfImageType") + private val pdfImageType: ImageType, ) { private val komgaAuthor = OpdsAuthor("Komga", URI("https://github.com/gotson/komga")) @@ -681,7 +686,11 @@ class OpdsController( } private fun BookDto.toOpdsEntry(media: Media, prepend: (BookDto) -> String = { "" }): OpdsEntryAcquisition { - val mediaTypes = media.pages.map { it.mediaType }.distinct() + val mediaTypes = when (media.profile) { + MediaProfile.DIVINA -> media.pages.map { it.mediaType }.distinct() + MediaProfile.PDF -> listOf(pdfImageType.mediaType) + null -> emptyList() + } val opdsLinkPageStreaming = if (mediaTypes.size == 1 && mediaTypes.first() in opdsPseSupportedFormats) { OpdsLinkPageStreaming(mediaTypes.first(), uriBuilder("books/$id/pages/").toUriString() + "{pageNumber}", media.pageCount, readProgress?.page, readProgress?.readDate) @@ -700,7 +709,7 @@ class OpdsController( authors = metadata.authors.map { OpdsAuthor(it.name) }, links = listOf( OpdsLinkImageThumbnail("image/jpeg", uriBuilder("books/$id/thumbnail/small").toUriString()), - OpdsLinkImage(media.pages[0].mediaType, uriBuilder("books/$id/thumbnail").toUriString()), + OpdsLinkImage(if (media.profile == MediaProfile.PDF) pdfImageType.mediaType else media.pages[0].mediaType, uriBuilder("books/$id/thumbnail").toUriString()), OpdsLinkFileAcquisition(media.mediaType, uriBuilder("books/$id/file/${sanitize(FilenameUtils.getName(url))}").toUriString()), opdsLinkPageStreaming, ), diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v2/Opds2Controller.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v2/Opds2Controller.kt index b0719d53..c50bf977 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v2/Opds2Controller.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v2/Opds2Controller.kt @@ -16,11 +16,11 @@ import org.gotson.komga.domain.persistence.SeriesCollectionRepository import org.gotson.komga.infrastructure.jooq.toCurrentTimeZone import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.swagger.PageAsQueryParam +import org.gotson.komga.interfaces.api.WebPubGenerator import org.gotson.komga.interfaces.api.checkContentRestriction import org.gotson.komga.interfaces.api.dto.MEDIATYPE_OPDS_JSON_VALUE import org.gotson.komga.interfaces.api.dto.OpdsLinkRel import org.gotson.komga.interfaces.api.dto.WPLinkDto -import org.gotson.komga.interfaces.api.dto.toOpdsPublicationDto import org.gotson.komga.interfaces.api.opds.v2.dto.FacetDto import org.gotson.komga.interfaces.api.opds.v2.dto.FeedDto import org.gotson.komga.interfaces.api.opds.v2.dto.FeedGroupDto @@ -58,6 +58,7 @@ class Opds2Controller( private val seriesDtoRepository: SeriesDtoRepository, private val bookDtoRepository: BookDtoRepository, private val referentialRepository: ReferentialRepository, + private val webPubGenerator: WebPubGenerator, ) { private fun linkStart() = WPLinkDto( title = "Home", @@ -154,14 +155,14 @@ class Opds2Controller( principal.user.id, PageRequest.of(0, RECOMMENDED_ITEMS_NUMBER, Sort.by(Sort.Order.desc("readProgress.readDate"))), principal.user.restrictions, - ).map { it.toOpdsPublicationDto(true) } + ).map { webPubGenerator.toOpdsPublicationDto(it, true) } val onDeck = bookDtoRepository.findAllOnDeck( principal.user.id, authorizedLibraryIds, Pageable.ofSize(RECOMMENDED_ITEMS_NUMBER), principal.user.restrictions, - ).map { it.toOpdsPublicationDto(true) } + ).map { webPubGenerator.toOpdsPublicationDto(it, true) } val latestBooks = bookDtoRepository.findAll( BookSearchWithReadProgress( @@ -172,7 +173,7 @@ class Opds2Controller( principal.user.id, PageRequest.of(0, RECOMMENDED_ITEMS_NUMBER, Sort.by(Sort.Order.desc("createdDate"))), principal.user.restrictions, - ).map { it.toOpdsPublicationDto(true) } + ).map { webPubGenerator.toOpdsPublicationDto(it, true) } val latestSeries = seriesDtoRepository.findAll( SeriesSearchWithReadProgress( @@ -244,7 +245,7 @@ class Opds2Controller( principal.user.id, PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.desc("readProgress.readDate"))), principal.user.restrictions, - ).map { it.toOpdsPublicationDto(true) } + ).map { webPubGenerator.toOpdsPublicationDto(it, true) } val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/keep-reading") @@ -279,7 +280,7 @@ class Opds2Controller( authorizedLibraryIds, page, principal.user.restrictions, - ).map { it.toOpdsPublicationDto(true) } + ).map { webPubGenerator.toOpdsPublicationDto(it, true) } val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/on-deck") @@ -318,7 +319,7 @@ class Opds2Controller( principal.user.id, PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.desc("createdDate"))), principal.user.restrictions, - ).map { it.toOpdsPublicationDto(true) } + ).map { webPubGenerator.toOpdsPublicationDto(it, true) } val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/books/latest") @@ -570,9 +571,7 @@ class Opds2Controller( principal.user.restrictions, ) - val entries = booksPage.map { bookDto -> - bookDto.toOpdsPublicationDto(true) - } + val entries = booksPage.map { webPubGenerator.toOpdsPublicationDto(it, true) } val uriBuilder = uriBuilder("readlists/$id") @@ -625,7 +624,7 @@ class Opds2Controller( val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.asc("metadata.numberSort"))) val entries = bookDtoRepository.findAll(bookSearch, principal.user.id, pageable, principal.user.restrictions) - .map { bookDto -> bookDto.toOpdsPublicationDto(true) } + .map { webPubGenerator.toOpdsPublicationDto(it, true) } val uriBuilder = uriBuilder("series/$id") @@ -690,7 +689,7 @@ class Opds2Controller( principal.user.id, pageable, principal.user.restrictions, - ).map { it.toOpdsPublicationDto(true) } + ).map { webPubGenerator.toOpdsPublicationDto(it, true) } val resultsCollections = collectionRepository.findAll( principal.user.getAuthorizedLibraryIds(null), diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt index 9ebe737e..3c3c39dc 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt @@ -22,11 +22,7 @@ import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.MarkSelectedPreference import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.MediaNotReadyException -import org.gotson.komga.domain.model.MediaType.EPUB -import org.gotson.komga.domain.model.MediaType.PDF -import org.gotson.komga.domain.model.MediaType.RAR_4 -import org.gotson.komga.domain.model.MediaType.RAR_GENERIC -import org.gotson.komga.domain.model.MediaType.ZIP +import org.gotson.komga.domain.model.MediaProfile import org.gotson.komga.domain.model.MediaUnsupportedException import org.gotson.komga.domain.model.ROLE_ADMIN import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD @@ -51,12 +47,11 @@ import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam import org.gotson.komga.infrastructure.web.getMediaTypeOrDefault import org.gotson.komga.infrastructure.web.setCachePrivate +import org.gotson.komga.interfaces.api.WebPubGenerator import org.gotson.komga.interfaces.api.checkContentRestriction import org.gotson.komga.interfaces.api.dto.MEDIATYPE_DIVINA_JSON_VALUE import org.gotson.komga.interfaces.api.dto.MEDIATYPE_WEBPUB_JSON_VALUE import org.gotson.komga.interfaces.api.dto.WPPublicationDto -import org.gotson.komga.interfaces.api.dto.toManifestDivina -import org.gotson.komga.interfaces.api.dto.toManifestPdf import org.gotson.komga.interfaces.api.persistence.BookDtoRepository import org.gotson.komga.interfaces.api.rest.dto.BookDto import org.gotson.komga.interfaces.api.rest.dto.BookImportBatchDto @@ -128,6 +123,7 @@ class BookController( private val eventPublisher: ApplicationEventPublisher, private val thumbnailBookRepository: ThumbnailBookRepository, private val imageConverter: ImageConverter, + private val webPubGenerator: WebPubGenerator, ) { @PageableAsQueryParam @@ -446,15 +442,18 @@ class BookController( Media.Status.ERROR -> throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed") Media.Status.UNSUPPORTED -> throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book format is not supported") - Media.Status.READY -> media.pages.mapIndexed { index, bookPage -> - PageDto( - number = index + 1, - fileName = bookPage.fileName, - mediaType = bookPage.mediaType, - width = bookPage.dimension?.width, - height = bookPage.dimension?.height, - sizeBytes = bookPage.fileSize, - ) + Media.Status.READY -> { + val pages = if (media.profile == MediaProfile.PDF) bookAnalyzer.getPdfPagesDynamic(media) else media.pages + pages.mapIndexed { index, bookPage -> + PageDto( + number = index + 1, + fileName = bookPage.fileName, + mediaType = bookPage.mediaType, + width = bookPage.dimension?.width, + height = bookPage.dimension?.height, + sizeBytes = bookPage.fileSize, + ) + } } } } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) @@ -510,7 +509,7 @@ class BookController( principal.user.checkContentRestriction(book) - if (media.mediaType == PDF.type && acceptHeaders != null && acceptHeaders.any { it.isCompatibleWith(MediaType.APPLICATION_PDF) }) { + if (media.profile == MediaProfile.PDF && acceptHeaders != null && acceptHeaders.any { it.isCompatibleWith(MediaType.APPLICATION_PDF) }) { // keep only pdf and image acceptHeaders.removeIf { !it.isCompatibleWith(MediaType.APPLICATION_PDF) && !it.isCompatibleWith(MediaType("image")) } MimeTypeUtils.sortBySpecificity(acceptHeaders) @@ -655,12 +654,9 @@ class BookController( @PathVariable bookId: String, ): ResponseEntity = mediaRepository.findByIdOrNull(bookId)?.let { media -> - when (KomgaMediaType.fromMediaType(media.mediaType)) { - ZIP -> getWebPubManifestDivina(principal, bookId) - RAR_GENERIC -> getWebPubManifestDivina(principal, bookId) - RAR_4 -> getWebPubManifestDivina(principal, bookId) - EPUB -> getWebPubManifestDivina(principal, bookId) - PDF -> getWebPubManifestPdf(principal, bookId) + when (KomgaMediaType.fromMediaType(media.mediaType)?.profile) { + MediaProfile.DIVINA -> getWebPubManifestDivina(principal, bookId) + MediaProfile.PDF -> getWebPubManifestPdf(principal, bookId) null -> throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed") } } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) @@ -674,9 +670,10 @@ class BookController( @PathVariable bookId: String, ): ResponseEntity = bookDtoRepository.findByIdOrNull(bookId, principal.user.id)?.let { bookDto -> - if (bookDto.media.mediaType != PDF.type) throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Book media type '${bookDto.media.mediaType}' not compatible with requested profile") + if (bookDto.media.mediaProfile != MediaProfile.PDF.name) throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Book media type '${bookDto.media.mediaType}' not compatible with requested profile") principal.user.checkContentRestriction(bookDto) - val manifest = bookDto.toManifestPdf( + val manifest = webPubGenerator.toManifestPdf( + bookDto, mediaRepository.findById(bookDto.id), seriesMetadataRepository.findById(bookDto.seriesId), ) @@ -686,9 +683,7 @@ class BookController( } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) @GetMapping( - value = [ - "api/v1/books/{bookId}/manifest/divina", - ], + value = ["api/v1/books/{bookId}/manifest/divina"], produces = [MEDIATYPE_DIVINA_JSON_VALUE], ) fun getWebPubManifestDivina( @@ -697,8 +692,8 @@ class BookController( ): ResponseEntity = bookDtoRepository.findByIdOrNull(bookId, principal.user.id)?.let { bookDto -> principal.user.checkContentRestriction(bookDto) - val manifest = bookDto.toManifestDivina( - imageConverter::canConvertMediaType, + val manifest = webPubGenerator.toManifestDivina( + bookDto, mediaRepository.findById(bookDto.id), seriesMetadataRepository.findById(bookDto.seriesId), ) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/TransientBooksController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/TransientBooksController.kt index 50dced5b..6a95ed58 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/TransientBooksController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/TransientBooksController.kt @@ -5,8 +5,10 @@ import mu.KotlinLogging import org.gotson.komga.domain.model.BookWithMedia import org.gotson.komga.domain.model.CodedException import org.gotson.komga.domain.model.MediaNotReadyException +import org.gotson.komga.domain.model.MediaProfile import org.gotson.komga.domain.model.ROLE_ADMIN import org.gotson.komga.domain.persistence.TransientBookRepository +import org.gotson.komga.domain.service.BookAnalyzer import org.gotson.komga.domain.service.TransientBookLifecycle import org.gotson.komga.infrastructure.web.getMediaTypeOrDefault import org.gotson.komga.infrastructure.web.toFilePath @@ -33,6 +35,7 @@ private val logger = KotlinLogging.logger {} class TransientBooksController( private val transientBookLifecycle: TransientBookLifecycle, private val transientBookRepository: TransientBookRepository, + private val bookAnalyzer: BookAnalyzer, ) { @PostMapping @@ -78,30 +81,32 @@ class TransientBooksController( throw ResponseStatusException(HttpStatus.NOT_FOUND, "File not found, it may have moved") } } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) -} -private fun BookWithMedia.toDto() = - TransientBookDto( - id = book.id, - name = book.name, - url = book.url.toFilePath(), - fileLastModified = book.fileLastModified, - sizeBytes = book.fileSize, - status = media.status.toString(), - mediaType = media.mediaType ?: "", - pages = media.pages.mapIndexed { index, bookPage -> - PageDto( - number = index + 1, - fileName = bookPage.fileName, - mediaType = bookPage.mediaType, - width = bookPage.dimension?.width, - height = bookPage.dimension?.height, - sizeBytes = bookPage.fileSize, - ) - }, - files = media.files, - comment = media.comment ?: "", - ) + private fun BookWithMedia.toDto(): TransientBookDto { + val pages = if (media.profile == MediaProfile.PDF) bookAnalyzer.getPdfPagesDynamic(media) else media.pages + return TransientBookDto( + id = book.id, + name = book.name, + url = book.url.toFilePath(), + fileLastModified = book.fileLastModified, + sizeBytes = book.fileSize, + status = media.status.toString(), + mediaType = media.mediaType ?: "", + pages = pages.mapIndexed { index, bookPage -> + PageDto( + number = index + 1, + fileName = bookPage.fileName, + mediaType = bookPage.mediaType, + width = bookPage.dimension?.width, + height = bookPage.dimension?.height, + sizeBytes = bookPage.fileSize, + ) + }, + files = media.files, + comment = media.comment ?: "", + ) + } +} data class ScanRequestDto( val path: String, diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/BookDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/BookDto.kt index 46f485a7..5607b217 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/BookDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/BookDto.kt @@ -3,6 +3,7 @@ package org.gotson.komga.interfaces.api.rest.dto import com.fasterxml.jackson.annotation.JsonFormat import com.jakewharton.byteunits.BinaryByteUnit import org.apache.commons.io.FilenameUtils +import org.gotson.komga.domain.model.MediaType import java.time.LocalDate import java.time.LocalDateTime @@ -38,7 +39,9 @@ data class MediaDto( val mediaType: String, val pagesCount: Int, val comment: String, -) +) { + val mediaProfile: String by lazy { MediaType.fromMediaType(mediaType)?.profile?.name ?: "" } +} data class BookMetadataDto( val title: String, diff --git a/komga/src/test/kotlin/org/gotson/komga/infrastructure/mediacontainer/PdfExtractorTest.kt b/komga/src/test/kotlin/org/gotson/komga/infrastructure/mediacontainer/PdfExtractorTest.kt index 6e643722..70914ca2 100644 --- a/komga/src/test/kotlin/org/gotson/komga/infrastructure/mediacontainer/PdfExtractorTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/mediacontainer/PdfExtractorTest.kt @@ -1,40 +1,20 @@ package org.gotson.komga.infrastructure.mediacontainer -import org.assertj.core.api.Assertions -import org.gotson.komga.domain.model.Dimension +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.infrastructure.image.ImageType import org.junit.jupiter.api.Test import org.springframework.core.io.ClassPathResource class PdfExtractorTest { - private val pdfExtractor = PdfExtractor() + private val pdfExtractor = PdfExtractor(ImageType.JPEG, 1000F) @Test - fun `given pdf file when parsing for entries then returns all images`() { + fun `given pdf file when getting pages then pages are returned`() { val fileResource = ClassPathResource("pdf/komga.pdf") - val entries = pdfExtractor.getEntries(fileResource.file.toPath(), true) + val pages = pdfExtractor.getPages(fileResource.file.toPath(), true) - Assertions.assertThat(entries).hasSize(1) - with(entries.first()) { - Assertions.assertThat(name).isEqualTo("0") - Assertions.assertThat(mediaType).isEqualTo("image/jpeg") - Assertions.assertThat(dimension).isEqualTo(Dimension(1536, 1536)) - Assertions.assertThat(fileSize).isNull() - } - } - - @Test - fun `given pdf file when parsing for entries without analyzing dimensions then returns all images without dimensions`() { - val fileResource = ClassPathResource("pdf/komga.pdf") - - val entries = pdfExtractor.getEntries(fileResource.file.toPath(), false) - - Assertions.assertThat(entries).hasSize(1) - with(entries.first()) { - Assertions.assertThat(name).isEqualTo("0") - Assertions.assertThat(mediaType).isEqualTo("image/jpeg") - Assertions.assertThat(dimension).isNull() - Assertions.assertThat(fileSize).isNull() - } + assertThat(pages).hasSize(1) + assertThat(pages.first().dimension?.width).isEqualTo(512) } }