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:
Gauthier Roebroeck 2023-11-15 16:46:36 +08:00
parent 21e3e7a269
commit d6680a4f42
20 changed files with 416 additions and 337 deletions

View file

@ -0,0 +1,4 @@
update media
set STATUS = 'OUTDATED'
where MEDIA_TYPE = 'application/pdf'
and STATUS = 'READY';

View file

@ -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
}

View file

@ -0,0 +1,6 @@
package org.gotson.komga.domain.model
enum class MediaProfile {
DIVINA,
PDF,
}

View file

@ -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 {

View file

@ -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) },
)
}
}
}

View file

@ -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 {

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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,
) {

View file

@ -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
}

View file

@ -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())
}
}

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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,
),

View file

@ -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),

View file

@ -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),
)

View file

@ -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,

View file

@ -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,

View file

@ -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)
}
}