mirror of
https://github.com/gotson/komga.git
synced 2025-12-06 16:42:24 +01:00
refactor(komga): introduce media profile for PDF
pdf pages in DB are stored with their original size, and the dimension and media type are generated on the fly
This commit is contained in:
parent
21e3e7a269
commit
d6680a4f42
20 changed files with 416 additions and 337 deletions
|
|
@ -0,0 +1,4 @@
|
|||
update media
|
||||
set STATUS = 'OUTDATED'
|
||||
where MEDIA_TYPE = 'application/pdf'
|
||||
and STATUS = 'READY';
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
package org.gotson.komga.domain.model
|
||||
|
||||
enum class MediaProfile {
|
||||
DIVINA,
|
||||
PDF,
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<MediaContainerExtractor>,
|
||||
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<BookPage> {
|
||||
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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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<BookWithMedia> {
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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<String> = listOf(MediaType.PDF.type)
|
||||
|
||||
override fun getEntries(path: Path, analyzeDimensions: Boolean): List<MediaContainerEntry> =
|
||||
fun getPages(path: Path, analyzeDimensions: Boolean): List<MediaContainerEntry> =
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AuthorDto>): 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<WPLinkDto> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AuthorDto>): 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<WPLinkDto> {
|
||||
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
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,7 +442,9 @@ 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 ->
|
||||
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,
|
||||
|
|
@ -457,6 +455,7 @@ class BookController(
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
|
||||
@ApiResponse(content = [Content(mediaType = "image/*", schema = Schema(type = "string", format = "binary"))])
|
||||
|
|
@ -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<WPPublicationDto> =
|
||||
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<WPPublicationDto> =
|
||||
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<WPPublicationDto> =
|
||||
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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,10 +81,10 @@ class TransientBooksController(
|
|||
throw ResponseStatusException(HttpStatus.NOT_FOUND, "File not found, it may have moved")
|
||||
}
|
||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
}
|
||||
|
||||
private fun BookWithMedia.toDto() =
|
||||
TransientBookDto(
|
||||
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(),
|
||||
|
|
@ -89,7 +92,7 @@ private fun BookWithMedia.toDto() =
|
|||
sizeBytes = book.fileSize,
|
||||
status = media.status.toString(),
|
||||
mediaType = media.mediaType ?: "",
|
||||
pages = media.pages.mapIndexed { index, bookPage ->
|
||||
pages = pages.mapIndexed { index, bookPage ->
|
||||
PageDto(
|
||||
number = index + 1,
|
||||
fileName = bookPage.fileName,
|
||||
|
|
@ -102,6 +105,8 @@ private fun BookWithMedia.toDto() =
|
|||
files = media.files,
|
||||
comment = media.comment ?: "",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class ScanRequestDto(
|
||||
val path: String,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue