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,
|
override val lastModifiedDate: LocalDateTime = createdDate,
|
||||||
) : Auditable {
|
) : Auditable {
|
||||||
|
|
||||||
|
@delegate:Transient
|
||||||
|
val profile: MediaProfile? by lazy { MediaType.fromMediaType(mediaType)?.profile }
|
||||||
|
|
||||||
enum class Status {
|
enum class Status {
|
||||||
UNKNOWN, ERROR, READY, UNSUPPORTED, OUTDATED
|
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
|
package org.gotson.komga.domain.model
|
||||||
|
|
||||||
enum class MediaType(val type: String, val fileExtension: String, val exportType: String = type) {
|
enum class MediaType(val type: String, val profile: MediaProfile, val fileExtension: String, val exportType: String = type) {
|
||||||
ZIP("application/zip", "cbz", "application/vnd.comicbook+zip"),
|
ZIP("application/zip", MediaProfile.DIVINA, "cbz", "application/vnd.comicbook+zip"),
|
||||||
RAR_GENERIC("application/x-rar-compressed", "cbr", "application/vnd.comicbook-rar"),
|
RAR_GENERIC("application/x-rar-compressed", MediaProfile.DIVINA, "cbr", "application/vnd.comicbook-rar"),
|
||||||
RAR_4("application/x-rar-compressed; version=4", "cbr", "application/vnd.comicbook-rar"),
|
RAR_4("application/x-rar-compressed; version=4", MediaProfile.DIVINA, "cbr", "application/vnd.comicbook-rar"),
|
||||||
EPUB("application/epub+zip", "epub"),
|
EPUB("application/epub+zip", MediaProfile.DIVINA, "epub"),
|
||||||
PDF("application/pdf", "pdf"),
|
PDF("application/pdf", MediaProfile.PDF, "pdf"),
|
||||||
;
|
;
|
||||||
|
|
||||||
companion object {
|
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.Dimension
|
||||||
import org.gotson.komga.domain.model.Media
|
import org.gotson.komga.domain.model.Media
|
||||||
import org.gotson.komga.domain.model.MediaNotReadyException
|
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.MediaUnsupportedException
|
||||||
import org.gotson.komga.domain.model.ThumbnailBook
|
import org.gotson.komga.domain.model.ThumbnailBook
|
||||||
import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider
|
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.ContentDetector
|
||||||
import org.gotson.komga.infrastructure.mediacontainer.CoverExtractor
|
import org.gotson.komga.infrastructure.mediacontainer.CoverExtractor
|
||||||
import org.gotson.komga.infrastructure.mediacontainer.MediaContainerExtractor
|
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.beans.factory.annotation.Value
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
|
|
@ -32,32 +35,42 @@ private val logger = KotlinLogging.logger {}
|
||||||
class BookAnalyzer(
|
class BookAnalyzer(
|
||||||
private val contentDetector: ContentDetector,
|
private val contentDetector: ContentDetector,
|
||||||
extractors: List<MediaContainerExtractor>,
|
extractors: List<MediaContainerExtractor>,
|
||||||
|
private val pdfExtractor: PdfExtractor,
|
||||||
private val imageConverter: ImageConverter,
|
private val imageConverter: ImageConverter,
|
||||||
private val imageAnalyzer: ImageAnalyzer,
|
private val imageAnalyzer: ImageAnalyzer,
|
||||||
private val hasher: Hasher,
|
private val hasher: Hasher,
|
||||||
@Value("#{@komgaProperties.pageHashing}") private val pageHashing: Int,
|
@Value("#{@komgaProperties.pageHashing}") private val pageHashing: Int,
|
||||||
private val komgaSettingsProvider: KomgaSettingsProvider,
|
private val komgaSettingsProvider: KomgaSettingsProvider,
|
||||||
|
@Qualifier("thumbnailType")
|
||||||
private val thumbnailType: ImageType,
|
private val thumbnailType: ImageType,
|
||||||
|
@Qualifier("pdfImageType")
|
||||||
|
private val pdfImageType: ImageType,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val supportedMediaTypes = extractors
|
val supportedMediaTypes = extractors
|
||||||
.flatMap { e -> e.mediaTypes().map { it to e } }
|
.flatMap { e -> e.mediaTypes().map { it to e } }
|
||||||
.toMap()
|
.toMap()
|
||||||
|
|
||||||
fun analyze(book: Book, analyzeDimensions: Boolean): Media {
|
fun analyze(book: Book, analyzeDimensions: Boolean): Media {
|
||||||
logger.info { "Trying to analyze book: $book" }
|
logger.info { "Trying to analyze book: $book" }
|
||||||
try {
|
try {
|
||||||
val mediaType = contentDetector.detectMediaType(book.path)
|
val mediaType = contentDetector.detectMediaType(book.path).let {
|
||||||
logger.info { "Detected media type: $mediaType" }
|
logger.info { "Detected media type: $it" }
|
||||||
if (!supportedMediaTypes.containsKey(mediaType))
|
MediaType.fromMediaType(it) ?: return Media(mediaType = it, status = Media.Status.UNSUPPORTED, comment = "ERR_1001", bookId = book.id)
|
||||||
return Media(mediaType = mediaType, 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 {
|
val entries = try {
|
||||||
supportedMediaTypes.getValue(mediaType).getEntries(book.path, analyzeDimensions)
|
supportedMediaTypes.getValue(mediaType.type).getEntries(book.path, analyzeDimensions)
|
||||||
} catch (ex: MediaUnsupportedException) {
|
} 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) {
|
} catch (ex: Exception) {
|
||||||
logger.error(ex) { "Error while analyzing book: $book" }
|
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
|
val (pages, others) = entries
|
||||||
|
|
@ -78,13 +91,13 @@ class BookAnalyzer(
|
||||||
|
|
||||||
if (pages.isEmpty()) {
|
if (pages.isEmpty()) {
|
||||||
logger.warn { "Book $book does not contain any pages" }
|
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" }
|
logger.info { "Book has ${pages.size} pages" }
|
||||||
|
|
||||||
val files = others.map { it.name }
|
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) {
|
} catch (ade: AccessDeniedException) {
|
||||||
logger.error(ade) { "Error while analyzing book: $book" }
|
logger.error(ade) { "Error while analyzing book: $book" }
|
||||||
return Media(status = Media.Status.ERROR, comment = "ERR_1000", bookId = book.id)
|
return Media(status = Media.Status.ERROR, comment = "ERR_1000", bookId = book.id)
|
||||||
|
|
@ -107,7 +120,7 @@ class BookAnalyzer(
|
||||||
}
|
}
|
||||||
|
|
||||||
val thumbnail = try {
|
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
|
// try to get the cover from a CoverExtractor first
|
||||||
var coverBytes: ByteArray? = if (extractor is CoverExtractor) {
|
var coverBytes: ByteArray? = if (extractor is CoverExtractor) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -118,9 +131,12 @@ class BookAnalyzer(
|
||||||
}
|
}
|
||||||
} else null
|
} else null
|
||||||
// if no cover could be found, get the first page
|
// 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)
|
imageConverter.resizeImageToByteArray(cover, thumbnailType, komgaSettingsProvider.thumbnailSize.maxEdge)
|
||||||
}
|
}
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
|
|
@ -155,7 +171,11 @@ class BookAnalyzer(
|
||||||
throw IndexOutOfBoundsException("Page $number does not exist")
|
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(
|
@Throws(
|
||||||
|
|
@ -175,10 +195,9 @@ class BookAnalyzer(
|
||||||
throw IndexOutOfBoundsException("Page $number does not exist")
|
throw IndexOutOfBoundsException("Page $number does not exist")
|
||||||
}
|
}
|
||||||
|
|
||||||
val extractor = supportedMediaTypes.getValue(book.media.mediaType!!)
|
if (book.media.profile != MediaProfile.PDF) throw MediaUnsupportedException("Extractor does not support raw extraction of pages")
|
||||||
if (extractor !is MediaContainerRawExtractor) 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(
|
@Throws(
|
||||||
|
|
@ -192,6 +211,8 @@ class BookAnalyzer(
|
||||||
throw MediaNotReadyException()
|
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)
|
return supportedMediaTypes.getValue(book.media.mediaType!!).getEntryStream(book.book.path, fileName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,4 +251,15 @@ class BookAnalyzer(
|
||||||
|
|
||||||
return hasher.computeHash(bytes.inputStream())
|
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.MarkSelectedPreference
|
||||||
import org.gotson.komga.domain.model.Media
|
import org.gotson.komga.domain.model.Media
|
||||||
import org.gotson.komga.domain.model.MediaNotReadyException
|
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.ReadProgress
|
||||||
import org.gotson.komga.domain.model.ThumbnailBook
|
import org.gotson.komga.domain.model.ThumbnailBook
|
||||||
import org.gotson.komga.domain.persistence.BookMetadataRepository
|
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.hash.Hasher
|
||||||
import org.gotson.komga.infrastructure.image.ImageConverter
|
import org.gotson.komga.infrastructure.image.ImageConverter
|
||||||
import org.gotson.komga.infrastructure.image.ImageType
|
import org.gotson.komga.infrastructure.image.ImageType
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
import org.springframework.data.domain.Sort
|
import org.springframework.data.domain.Sort
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
|
@ -58,6 +60,8 @@ class BookLifecycle(
|
||||||
private val hasher: Hasher,
|
private val hasher: Hasher,
|
||||||
private val historicalEventRepository: HistoricalEventRepository,
|
private val historicalEventRepository: HistoricalEventRepository,
|
||||||
private val komgaSettingsProvider: KomgaSettingsProvider,
|
private val komgaSettingsProvider: KomgaSettingsProvider,
|
||||||
|
@Qualifier("pdfImageType")
|
||||||
|
private val pdfImageType: ImageType,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val resizeTargetFormat = ImageType.JPEG
|
private val resizeTargetFormat = ImageType.JPEG
|
||||||
|
|
@ -252,7 +256,9 @@ class BookLifecycle(
|
||||||
fun getBookPage(book: Book, number: Int, convertTo: ImageType? = null, resizeTo: Int? = null): BookPageContent {
|
fun getBookPage(book: Book, number: Int, convertTo: ImageType? = null, resizeTo: Int? = null): BookPageContent {
|
||||||
val media = mediaRepository.findById(book.id)
|
val media = mediaRepository.findById(book.id)
|
||||||
val pageContent = bookAnalyzer.getPageContent(BookWithMedia(book, media), number)
|
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) {
|
if (resizeTo != null) {
|
||||||
val convertedPage = try {
|
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.BookWithMedia
|
||||||
import org.gotson.komga.domain.model.Media
|
import org.gotson.komga.domain.model.Media
|
||||||
import org.gotson.komga.domain.model.MediaNotReadyException
|
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.model.PathContainedInPath
|
||||||
import org.gotson.komga.domain.persistence.LibraryRepository
|
import org.gotson.komga.domain.persistence.LibraryRepository
|
||||||
import org.gotson.komga.domain.persistence.TransientBookRepository
|
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 org.springframework.stereotype.Service
|
||||||
import java.nio.file.Paths
|
import java.nio.file.Paths
|
||||||
|
|
||||||
|
|
@ -16,6 +19,8 @@ class TransientBookLifecycle(
|
||||||
private val bookAnalyzer: BookAnalyzer,
|
private val bookAnalyzer: BookAnalyzer,
|
||||||
private val fileSystemScanner: FileSystemScanner,
|
private val fileSystemScanner: FileSystemScanner,
|
||||||
private val libraryRepository: LibraryRepository,
|
private val libraryRepository: LibraryRepository,
|
||||||
|
@Qualifier("pdfImageType")
|
||||||
|
private val pdfImageType: ImageType,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun scanAndPersist(filePath: String): List<BookWithMedia> {
|
fun scanAndPersist(filePath: String): List<BookWithMedia> {
|
||||||
|
|
@ -47,7 +52,9 @@ class TransientBookLifecycle(
|
||||||
)
|
)
|
||||||
fun getBookPage(transientBook: BookWithMedia, number: Int): BookPageContent {
|
fun getBookPage(transientBook: BookWithMedia, number: Int): BookPageContent {
|
||||||
val pageContent = bookAnalyzer.getPageContent(transientBook, number)
|
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)
|
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
|
package org.gotson.komga.infrastructure.image
|
||||||
|
|
||||||
import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider
|
import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.awt.image.BufferedImage
|
import java.awt.image.BufferedImage
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
|
|
@ -10,6 +11,7 @@ import kotlin.math.roundToInt
|
||||||
@Service
|
@Service
|
||||||
class MosaicGenerator(
|
class MosaicGenerator(
|
||||||
private val komgaSettingsProvider: KomgaSettingsProvider,
|
private val komgaSettingsProvider: KomgaSettingsProvider,
|
||||||
|
@Qualifier("thumbnailType")
|
||||||
private val thumbnailType: ImageType,
|
private val thumbnailType: ImageType,
|
||||||
private val imageConverter: ImageConverter,
|
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.multipdf.PageExtractor
|
||||||
import org.apache.pdfbox.pdmodel.PDDocument
|
import org.apache.pdfbox.pdmodel.PDDocument
|
||||||
import org.apache.pdfbox.pdmodel.PDPage
|
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.apache.pdfbox.rendering.PDFRenderer
|
||||||
import org.gotson.komga.domain.model.BookPageContent
|
import org.gotson.komga.domain.model.BookPageContent
|
||||||
import org.gotson.komga.domain.model.Dimension
|
import org.gotson.komga.domain.model.Dimension
|
||||||
import org.gotson.komga.domain.model.MediaContainerEntry
|
import org.gotson.komga.domain.model.MediaContainerEntry
|
||||||
import org.gotson.komga.domain.model.MediaType
|
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 org.springframework.stereotype.Service
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
|
@ -20,39 +22,37 @@ import kotlin.math.roundToInt
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@Service
|
@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"
|
fun getPages(path: Path, analyzeDimensions: Boolean): List<MediaContainerEntry> =
|
||||||
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> =
|
|
||||||
PDDocument.load(path.toFile(), MemoryUsageSetting.setupTempFileOnly()).use { pdf ->
|
PDDocument.load(path.toFile(), MemoryUsageSetting.setupTempFileOnly()).use { pdf ->
|
||||||
(0 until pdf.numberOfPages).map { index ->
|
(0 until pdf.numberOfPages).map { index ->
|
||||||
val page = pdf.getPage(index)
|
val page = pdf.getPage(index)
|
||||||
val scale = page.getScale()
|
val dimension = if (analyzeDimensions) Dimension(page.cropBox.width.roundToInt(), page.cropBox.height.roundToInt()) else null
|
||||||
val dimension = if (analyzeDimensions) Dimension((page.cropBox.width * scale).roundToInt(), (page.cropBox.height * scale).roundToInt()) else null
|
MediaContainerEntry(name = "${index + 1}", dimension = dimension)
|
||||||
MediaContainerEntry(name = index.toString(), mediaType = mediaType, 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 ->
|
PDDocument.load(path.toFile(), MemoryUsageSetting.setupTempFileOnly()).use { pdf ->
|
||||||
val pageNumber = entryName.toInt()
|
|
||||||
val page = pdf.getPage(pageNumber)
|
val page = pdf.getPage(pageNumber)
|
||||||
val image = PDFRenderer(pdf).renderImage(pageNumber, page.getScale(), ImageType.RGB)
|
val image = PDFRenderer(pdf).renderImage(pageNumber - 1, page.getScale(), RGB)
|
||||||
return ByteArrayOutputStream().use { out ->
|
val bytes = ByteArrayOutputStream().use { out ->
|
||||||
ImageIO.write(image, imageIOFormat, out)
|
ImageIO.write(image, imageType.imageIOFormat, out)
|
||||||
out.toByteArray()
|
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 ->
|
PDDocument.load(path.toFile(), MemoryUsageSetting.setupTempFileOnly()).use { pdf ->
|
||||||
val pageNumber = entryName.toInt() + 1
|
|
||||||
val bytes = ByteArrayOutputStream().use { out ->
|
val bytes = ByteArrayOutputStream().use { out ->
|
||||||
PageExtractor(pdf, pageNumber, pageNumber).extract().save(out)
|
PageExtractor(pdf, pageNumber, pageNumber).extract().save(out)
|
||||||
out.toByteArray()
|
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.BookSearchWithReadProgress
|
||||||
import org.gotson.komga.domain.model.Library
|
import org.gotson.komga.domain.model.Library
|
||||||
import org.gotson.komga.domain.model.Media
|
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.ReadList
|
||||||
import org.gotson.komga.domain.model.ReadStatus
|
import org.gotson.komga.domain.model.ReadStatus
|
||||||
import org.gotson.komga.domain.model.SeriesCollection
|
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.persistence.SeriesMetadataRepository
|
||||||
import org.gotson.komga.domain.service.BookLifecycle
|
import org.gotson.komga.domain.service.BookLifecycle
|
||||||
import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider
|
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.jooq.toCurrentTimeZone
|
||||||
import org.gotson.komga.infrastructure.security.KomgaPrincipal
|
import org.gotson.komga.infrastructure.security.KomgaPrincipal
|
||||||
import org.gotson.komga.infrastructure.swagger.PageAsQueryParam
|
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.persistence.SeriesDtoRepository
|
||||||
import org.gotson.komga.interfaces.api.rest.dto.BookDto
|
import org.gotson.komga.interfaces.api.rest.dto.BookDto
|
||||||
import org.gotson.komga.interfaces.api.rest.dto.SeriesDto
|
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.Page
|
||||||
import org.springframework.data.domain.PageRequest
|
import org.springframework.data.domain.PageRequest
|
||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
|
|
@ -109,6 +112,8 @@ class OpdsController(
|
||||||
private val bookRepository: BookRepository,
|
private val bookRepository: BookRepository,
|
||||||
private val bookLifecycle: BookLifecycle,
|
private val bookLifecycle: BookLifecycle,
|
||||||
private val komgaSettingsProvider: KomgaSettingsProvider,
|
private val komgaSettingsProvider: KomgaSettingsProvider,
|
||||||
|
@Qualifier("pdfImageType")
|
||||||
|
private val pdfImageType: ImageType,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val komgaAuthor = OpdsAuthor("Komga", URI("https://github.com/gotson/komga"))
|
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 {
|
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) {
|
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)
|
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) },
|
authors = metadata.authors.map { OpdsAuthor(it.name) },
|
||||||
links = listOf(
|
links = listOf(
|
||||||
OpdsLinkImageThumbnail("image/jpeg", uriBuilder("books/$id/thumbnail/small").toUriString()),
|
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()),
|
OpdsLinkFileAcquisition(media.mediaType, uriBuilder("books/$id/file/${sanitize(FilenameUtils.getName(url))}").toUriString()),
|
||||||
opdsLinkPageStreaming,
|
opdsLinkPageStreaming,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,11 @@ import org.gotson.komga.domain.persistence.SeriesCollectionRepository
|
||||||
import org.gotson.komga.infrastructure.jooq.toCurrentTimeZone
|
import org.gotson.komga.infrastructure.jooq.toCurrentTimeZone
|
||||||
import org.gotson.komga.infrastructure.security.KomgaPrincipal
|
import org.gotson.komga.infrastructure.security.KomgaPrincipal
|
||||||
import org.gotson.komga.infrastructure.swagger.PageAsQueryParam
|
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.checkContentRestriction
|
||||||
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_OPDS_JSON_VALUE
|
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.OpdsLinkRel
|
||||||
import org.gotson.komga.interfaces.api.dto.WPLinkDto
|
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.FacetDto
|
||||||
import org.gotson.komga.interfaces.api.opds.v2.dto.FeedDto
|
import org.gotson.komga.interfaces.api.opds.v2.dto.FeedDto
|
||||||
import org.gotson.komga.interfaces.api.opds.v2.dto.FeedGroupDto
|
import org.gotson.komga.interfaces.api.opds.v2.dto.FeedGroupDto
|
||||||
|
|
@ -58,6 +58,7 @@ class Opds2Controller(
|
||||||
private val seriesDtoRepository: SeriesDtoRepository,
|
private val seriesDtoRepository: SeriesDtoRepository,
|
||||||
private val bookDtoRepository: BookDtoRepository,
|
private val bookDtoRepository: BookDtoRepository,
|
||||||
private val referentialRepository: ReferentialRepository,
|
private val referentialRepository: ReferentialRepository,
|
||||||
|
private val webPubGenerator: WebPubGenerator,
|
||||||
) {
|
) {
|
||||||
private fun linkStart() = WPLinkDto(
|
private fun linkStart() = WPLinkDto(
|
||||||
title = "Home",
|
title = "Home",
|
||||||
|
|
@ -154,14 +155,14 @@ class Opds2Controller(
|
||||||
principal.user.id,
|
principal.user.id,
|
||||||
PageRequest.of(0, RECOMMENDED_ITEMS_NUMBER, Sort.by(Sort.Order.desc("readProgress.readDate"))),
|
PageRequest.of(0, RECOMMENDED_ITEMS_NUMBER, Sort.by(Sort.Order.desc("readProgress.readDate"))),
|
||||||
principal.user.restrictions,
|
principal.user.restrictions,
|
||||||
).map { it.toOpdsPublicationDto(true) }
|
).map { webPubGenerator.toOpdsPublicationDto(it, true) }
|
||||||
|
|
||||||
val onDeck = bookDtoRepository.findAllOnDeck(
|
val onDeck = bookDtoRepository.findAllOnDeck(
|
||||||
principal.user.id,
|
principal.user.id,
|
||||||
authorizedLibraryIds,
|
authorizedLibraryIds,
|
||||||
Pageable.ofSize(RECOMMENDED_ITEMS_NUMBER),
|
Pageable.ofSize(RECOMMENDED_ITEMS_NUMBER),
|
||||||
principal.user.restrictions,
|
principal.user.restrictions,
|
||||||
).map { it.toOpdsPublicationDto(true) }
|
).map { webPubGenerator.toOpdsPublicationDto(it, true) }
|
||||||
|
|
||||||
val latestBooks = bookDtoRepository.findAll(
|
val latestBooks = bookDtoRepository.findAll(
|
||||||
BookSearchWithReadProgress(
|
BookSearchWithReadProgress(
|
||||||
|
|
@ -172,7 +173,7 @@ class Opds2Controller(
|
||||||
principal.user.id,
|
principal.user.id,
|
||||||
PageRequest.of(0, RECOMMENDED_ITEMS_NUMBER, Sort.by(Sort.Order.desc("createdDate"))),
|
PageRequest.of(0, RECOMMENDED_ITEMS_NUMBER, Sort.by(Sort.Order.desc("createdDate"))),
|
||||||
principal.user.restrictions,
|
principal.user.restrictions,
|
||||||
).map { it.toOpdsPublicationDto(true) }
|
).map { webPubGenerator.toOpdsPublicationDto(it, true) }
|
||||||
|
|
||||||
val latestSeries = seriesDtoRepository.findAll(
|
val latestSeries = seriesDtoRepository.findAll(
|
||||||
SeriesSearchWithReadProgress(
|
SeriesSearchWithReadProgress(
|
||||||
|
|
@ -244,7 +245,7 @@ class Opds2Controller(
|
||||||
principal.user.id,
|
principal.user.id,
|
||||||
PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.desc("readProgress.readDate"))),
|
PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.desc("readProgress.readDate"))),
|
||||||
principal.user.restrictions,
|
principal.user.restrictions,
|
||||||
).map { it.toOpdsPublicationDto(true) }
|
).map { webPubGenerator.toOpdsPublicationDto(it, true) }
|
||||||
|
|
||||||
val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/keep-reading")
|
val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/keep-reading")
|
||||||
|
|
||||||
|
|
@ -279,7 +280,7 @@ class Opds2Controller(
|
||||||
authorizedLibraryIds,
|
authorizedLibraryIds,
|
||||||
page,
|
page,
|
||||||
principal.user.restrictions,
|
principal.user.restrictions,
|
||||||
).map { it.toOpdsPublicationDto(true) }
|
).map { webPubGenerator.toOpdsPublicationDto(it, true) }
|
||||||
|
|
||||||
val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/on-deck")
|
val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/on-deck")
|
||||||
|
|
||||||
|
|
@ -318,7 +319,7 @@ class Opds2Controller(
|
||||||
principal.user.id,
|
principal.user.id,
|
||||||
PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.desc("createdDate"))),
|
PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.desc("createdDate"))),
|
||||||
principal.user.restrictions,
|
principal.user.restrictions,
|
||||||
).map { it.toOpdsPublicationDto(true) }
|
).map { webPubGenerator.toOpdsPublicationDto(it, true) }
|
||||||
|
|
||||||
val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/books/latest")
|
val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/books/latest")
|
||||||
|
|
||||||
|
|
@ -570,9 +571,7 @@ class Opds2Controller(
|
||||||
principal.user.restrictions,
|
principal.user.restrictions,
|
||||||
)
|
)
|
||||||
|
|
||||||
val entries = booksPage.map { bookDto ->
|
val entries = booksPage.map { webPubGenerator.toOpdsPublicationDto(it, true) }
|
||||||
bookDto.toOpdsPublicationDto(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
val uriBuilder = uriBuilder("readlists/$id")
|
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 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)
|
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")
|
val uriBuilder = uriBuilder("series/$id")
|
||||||
|
|
||||||
|
|
@ -690,7 +689,7 @@ class Opds2Controller(
|
||||||
principal.user.id,
|
principal.user.id,
|
||||||
pageable,
|
pageable,
|
||||||
principal.user.restrictions,
|
principal.user.restrictions,
|
||||||
).map { it.toOpdsPublicationDto(true) }
|
).map { webPubGenerator.toOpdsPublicationDto(it, true) }
|
||||||
|
|
||||||
val resultsCollections = collectionRepository.findAll(
|
val resultsCollections = collectionRepository.findAll(
|
||||||
principal.user.getAuthorizedLibraryIds(null),
|
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.MarkSelectedPreference
|
||||||
import org.gotson.komga.domain.model.Media
|
import org.gotson.komga.domain.model.Media
|
||||||
import org.gotson.komga.domain.model.MediaNotReadyException
|
import org.gotson.komga.domain.model.MediaNotReadyException
|
||||||
import org.gotson.komga.domain.model.MediaType.EPUB
|
import org.gotson.komga.domain.model.MediaProfile
|
||||||
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.MediaUnsupportedException
|
import org.gotson.komga.domain.model.MediaUnsupportedException
|
||||||
import org.gotson.komga.domain.model.ROLE_ADMIN
|
import org.gotson.komga.domain.model.ROLE_ADMIN
|
||||||
import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD
|
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.swagger.PageableWithoutSortAsQueryParam
|
||||||
import org.gotson.komga.infrastructure.web.getMediaTypeOrDefault
|
import org.gotson.komga.infrastructure.web.getMediaTypeOrDefault
|
||||||
import org.gotson.komga.infrastructure.web.setCachePrivate
|
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.checkContentRestriction
|
||||||
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_DIVINA_JSON_VALUE
|
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.MEDIATYPE_WEBPUB_JSON_VALUE
|
||||||
import org.gotson.komga.interfaces.api.dto.WPPublicationDto
|
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.persistence.BookDtoRepository
|
||||||
import org.gotson.komga.interfaces.api.rest.dto.BookDto
|
import org.gotson.komga.interfaces.api.rest.dto.BookDto
|
||||||
import org.gotson.komga.interfaces.api.rest.dto.BookImportBatchDto
|
import org.gotson.komga.interfaces.api.rest.dto.BookImportBatchDto
|
||||||
|
|
@ -128,6 +123,7 @@ class BookController(
|
||||||
private val eventPublisher: ApplicationEventPublisher,
|
private val eventPublisher: ApplicationEventPublisher,
|
||||||
private val thumbnailBookRepository: ThumbnailBookRepository,
|
private val thumbnailBookRepository: ThumbnailBookRepository,
|
||||||
private val imageConverter: ImageConverter,
|
private val imageConverter: ImageConverter,
|
||||||
|
private val webPubGenerator: WebPubGenerator,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@PageableAsQueryParam
|
@PageableAsQueryParam
|
||||||
|
|
@ -446,7 +442,9 @@ class BookController(
|
||||||
|
|
||||||
Media.Status.ERROR -> throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed")
|
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.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(
|
PageDto(
|
||||||
number = index + 1,
|
number = index + 1,
|
||||||
fileName = bookPage.fileName,
|
fileName = bookPage.fileName,
|
||||||
|
|
@ -457,6 +455,7 @@ class BookController(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
|
|
||||||
@ApiResponse(content = [Content(mediaType = "image/*", schema = Schema(type = "string", format = "binary"))])
|
@ApiResponse(content = [Content(mediaType = "image/*", schema = Schema(type = "string", format = "binary"))])
|
||||||
|
|
@ -510,7 +509,7 @@ class BookController(
|
||||||
|
|
||||||
principal.user.checkContentRestriction(book)
|
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
|
// keep only pdf and image
|
||||||
acceptHeaders.removeIf { !it.isCompatibleWith(MediaType.APPLICATION_PDF) && !it.isCompatibleWith(MediaType("image")) }
|
acceptHeaders.removeIf { !it.isCompatibleWith(MediaType.APPLICATION_PDF) && !it.isCompatibleWith(MediaType("image")) }
|
||||||
MimeTypeUtils.sortBySpecificity(acceptHeaders)
|
MimeTypeUtils.sortBySpecificity(acceptHeaders)
|
||||||
|
|
@ -655,12 +654,9 @@ class BookController(
|
||||||
@PathVariable bookId: String,
|
@PathVariable bookId: String,
|
||||||
): ResponseEntity<WPPublicationDto> =
|
): ResponseEntity<WPPublicationDto> =
|
||||||
mediaRepository.findByIdOrNull(bookId)?.let { media ->
|
mediaRepository.findByIdOrNull(bookId)?.let { media ->
|
||||||
when (KomgaMediaType.fromMediaType(media.mediaType)) {
|
when (KomgaMediaType.fromMediaType(media.mediaType)?.profile) {
|
||||||
ZIP -> getWebPubManifestDivina(principal, bookId)
|
MediaProfile.DIVINA -> getWebPubManifestDivina(principal, bookId)
|
||||||
RAR_GENERIC -> getWebPubManifestDivina(principal, bookId)
|
MediaProfile.PDF -> getWebPubManifestPdf(principal, bookId)
|
||||||
RAR_4 -> getWebPubManifestDivina(principal, bookId)
|
|
||||||
EPUB -> getWebPubManifestDivina(principal, bookId)
|
|
||||||
PDF -> getWebPubManifestPdf(principal, bookId)
|
|
||||||
null -> throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed")
|
null -> throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed")
|
||||||
}
|
}
|
||||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
|
|
@ -674,9 +670,10 @@ class BookController(
|
||||||
@PathVariable bookId: String,
|
@PathVariable bookId: String,
|
||||||
): ResponseEntity<WPPublicationDto> =
|
): ResponseEntity<WPPublicationDto> =
|
||||||
bookDtoRepository.findByIdOrNull(bookId, principal.user.id)?.let { bookDto ->
|
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)
|
principal.user.checkContentRestriction(bookDto)
|
||||||
val manifest = bookDto.toManifestPdf(
|
val manifest = webPubGenerator.toManifestPdf(
|
||||||
|
bookDto,
|
||||||
mediaRepository.findById(bookDto.id),
|
mediaRepository.findById(bookDto.id),
|
||||||
seriesMetadataRepository.findById(bookDto.seriesId),
|
seriesMetadataRepository.findById(bookDto.seriesId),
|
||||||
)
|
)
|
||||||
|
|
@ -686,9 +683,7 @@ class BookController(
|
||||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
|
|
||||||
@GetMapping(
|
@GetMapping(
|
||||||
value = [
|
value = ["api/v1/books/{bookId}/manifest/divina"],
|
||||||
"api/v1/books/{bookId}/manifest/divina",
|
|
||||||
],
|
|
||||||
produces = [MEDIATYPE_DIVINA_JSON_VALUE],
|
produces = [MEDIATYPE_DIVINA_JSON_VALUE],
|
||||||
)
|
)
|
||||||
fun getWebPubManifestDivina(
|
fun getWebPubManifestDivina(
|
||||||
|
|
@ -697,8 +692,8 @@ class BookController(
|
||||||
): ResponseEntity<WPPublicationDto> =
|
): ResponseEntity<WPPublicationDto> =
|
||||||
bookDtoRepository.findByIdOrNull(bookId, principal.user.id)?.let { bookDto ->
|
bookDtoRepository.findByIdOrNull(bookId, principal.user.id)?.let { bookDto ->
|
||||||
principal.user.checkContentRestriction(bookDto)
|
principal.user.checkContentRestriction(bookDto)
|
||||||
val manifest = bookDto.toManifestDivina(
|
val manifest = webPubGenerator.toManifestDivina(
|
||||||
imageConverter::canConvertMediaType,
|
bookDto,
|
||||||
mediaRepository.findById(bookDto.id),
|
mediaRepository.findById(bookDto.id),
|
||||||
seriesMetadataRepository.findById(bookDto.seriesId),
|
seriesMetadataRepository.findById(bookDto.seriesId),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,10 @@ import mu.KotlinLogging
|
||||||
import org.gotson.komga.domain.model.BookWithMedia
|
import org.gotson.komga.domain.model.BookWithMedia
|
||||||
import org.gotson.komga.domain.model.CodedException
|
import org.gotson.komga.domain.model.CodedException
|
||||||
import org.gotson.komga.domain.model.MediaNotReadyException
|
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.model.ROLE_ADMIN
|
||||||
import org.gotson.komga.domain.persistence.TransientBookRepository
|
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.domain.service.TransientBookLifecycle
|
||||||
import org.gotson.komga.infrastructure.web.getMediaTypeOrDefault
|
import org.gotson.komga.infrastructure.web.getMediaTypeOrDefault
|
||||||
import org.gotson.komga.infrastructure.web.toFilePath
|
import org.gotson.komga.infrastructure.web.toFilePath
|
||||||
|
|
@ -33,6 +35,7 @@ private val logger = KotlinLogging.logger {}
|
||||||
class TransientBooksController(
|
class TransientBooksController(
|
||||||
private val transientBookLifecycle: TransientBookLifecycle,
|
private val transientBookLifecycle: TransientBookLifecycle,
|
||||||
private val transientBookRepository: TransientBookRepository,
|
private val transientBookRepository: TransientBookRepository,
|
||||||
|
private val bookAnalyzer: BookAnalyzer,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
|
|
@ -78,10 +81,10 @@ class TransientBooksController(
|
||||||
throw ResponseStatusException(HttpStatus.NOT_FOUND, "File not found, it may have moved")
|
throw ResponseStatusException(HttpStatus.NOT_FOUND, "File not found, it may have moved")
|
||||||
}
|
}
|
||||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
}
|
|
||||||
|
|
||||||
private fun BookWithMedia.toDto() =
|
private fun BookWithMedia.toDto(): TransientBookDto {
|
||||||
TransientBookDto(
|
val pages = if (media.profile == MediaProfile.PDF) bookAnalyzer.getPdfPagesDynamic(media) else media.pages
|
||||||
|
return TransientBookDto(
|
||||||
id = book.id,
|
id = book.id,
|
||||||
name = book.name,
|
name = book.name,
|
||||||
url = book.url.toFilePath(),
|
url = book.url.toFilePath(),
|
||||||
|
|
@ -89,7 +92,7 @@ private fun BookWithMedia.toDto() =
|
||||||
sizeBytes = book.fileSize,
|
sizeBytes = book.fileSize,
|
||||||
status = media.status.toString(),
|
status = media.status.toString(),
|
||||||
mediaType = media.mediaType ?: "",
|
mediaType = media.mediaType ?: "",
|
||||||
pages = media.pages.mapIndexed { index, bookPage ->
|
pages = pages.mapIndexed { index, bookPage ->
|
||||||
PageDto(
|
PageDto(
|
||||||
number = index + 1,
|
number = index + 1,
|
||||||
fileName = bookPage.fileName,
|
fileName = bookPage.fileName,
|
||||||
|
|
@ -102,6 +105,8 @@ private fun BookWithMedia.toDto() =
|
||||||
files = media.files,
|
files = media.files,
|
||||||
comment = media.comment ?: "",
|
comment = media.comment ?: "",
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data class ScanRequestDto(
|
data class ScanRequestDto(
|
||||||
val path: String,
|
val path: String,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package org.gotson.komga.interfaces.api.rest.dto
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat
|
import com.fasterxml.jackson.annotation.JsonFormat
|
||||||
import com.jakewharton.byteunits.BinaryByteUnit
|
import com.jakewharton.byteunits.BinaryByteUnit
|
||||||
import org.apache.commons.io.FilenameUtils
|
import org.apache.commons.io.FilenameUtils
|
||||||
|
import org.gotson.komga.domain.model.MediaType
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
|
@ -38,7 +39,9 @@ data class MediaDto(
|
||||||
val mediaType: String,
|
val mediaType: String,
|
||||||
val pagesCount: Int,
|
val pagesCount: Int,
|
||||||
val comment: String,
|
val comment: String,
|
||||||
)
|
) {
|
||||||
|
val mediaProfile: String by lazy { MediaType.fromMediaType(mediaType)?.profile?.name ?: "" }
|
||||||
|
}
|
||||||
|
|
||||||
data class BookMetadataDto(
|
data class BookMetadataDto(
|
||||||
val title: String,
|
val title: String,
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,20 @@
|
||||||
package org.gotson.komga.infrastructure.mediacontainer
|
package org.gotson.komga.infrastructure.mediacontainer
|
||||||
|
|
||||||
import org.assertj.core.api.Assertions
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
import org.gotson.komga.domain.model.Dimension
|
import org.gotson.komga.infrastructure.image.ImageType
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.springframework.core.io.ClassPathResource
|
import org.springframework.core.io.ClassPathResource
|
||||||
|
|
||||||
class PdfExtractorTest {
|
class PdfExtractorTest {
|
||||||
private val pdfExtractor = PdfExtractor()
|
private val pdfExtractor = PdfExtractor(ImageType.JPEG, 1000F)
|
||||||
|
|
||||||
@Test
|
@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 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)
|
assertThat(pages).hasSize(1)
|
||||||
with(entries.first()) {
|
assertThat(pages.first().dimension?.width).isEqualTo(512)
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue