mirror of
https://github.com/gotson/komga.git
synced 2026-05-08 12:35:30 +02:00
feat: get images dimension during media analysis
media analysis will get width and height for each image in a book this is required for #123
This commit is contained in:
parent
6dc1e198a3
commit
f9d55ecfd0
13 changed files with 123 additions and 25 deletions
|
|
@ -0,0 +1,4 @@
|
||||||
|
alter table media_page
|
||||||
|
add column width int NULL;
|
||||||
|
alter table media_page
|
||||||
|
add column height int NULL;
|
||||||
|
|
@ -2,5 +2,6 @@ package org.gotson.komga.domain.model
|
||||||
|
|
||||||
data class BookPage(
|
data class BookPage(
|
||||||
val fileName: String,
|
val fileName: String,
|
||||||
val mediaType: String
|
val mediaType: String,
|
||||||
|
val dimension: Dimension? = null
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package org.gotson.komga.domain.model
|
||||||
|
|
||||||
|
data class Dimension(
|
||||||
|
val width: Int,
|
||||||
|
val height: Int
|
||||||
|
)
|
||||||
|
|
@ -3,5 +3,6 @@ package org.gotson.komga.domain.model
|
||||||
data class MediaContainerEntry(
|
data class MediaContainerEntry(
|
||||||
val name: String,
|
val name: String,
|
||||||
val mediaType: String? = null,
|
val mediaType: String? = null,
|
||||||
val comment: String? = null
|
val comment: String? = null,
|
||||||
|
val dimension: Dimension? = null
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ class BookAnalyzer(
|
||||||
entry.mediaType?.let { contentDetector.isImage(it) } ?: false
|
entry.mediaType?.let { contentDetector.isImage(it) } ?: false
|
||||||
}.let { (images, others) ->
|
}.let { (images, others) ->
|
||||||
Pair(
|
Pair(
|
||||||
images.map { BookPage(it.name, it.mediaType!!) },
|
images.map { BookPage(it.name, it.mediaType!!, it.dimension) },
|
||||||
others
|
others
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
package org.gotson.komga.infrastructure.image
|
||||||
|
|
||||||
|
import mu.KotlinLogging
|
||||||
|
import org.gotson.komga.domain.model.Dimension
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import java.io.InputStream
|
||||||
|
import javax.imageio.ImageIO
|
||||||
|
|
||||||
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class ImageAnalyzer {
|
||||||
|
|
||||||
|
fun getDimension(stream: InputStream): Dimension? =
|
||||||
|
stream.use {
|
||||||
|
ImageIO.createImageInputStream(stream).use { fis ->
|
||||||
|
val readers = ImageIO.getImageReaders(fis)
|
||||||
|
if (readers.hasNext()) {
|
||||||
|
val reader = readers.next()
|
||||||
|
reader.input = fis
|
||||||
|
Dimension(reader.getWidth(0), reader.getHeight(0))
|
||||||
|
} else {
|
||||||
|
logger.warn { "no reader found" }
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package org.gotson.komga.infrastructure.jooq
|
package org.gotson.komga.infrastructure.jooq
|
||||||
|
|
||||||
import org.gotson.komga.domain.model.BookPage
|
import org.gotson.komga.domain.model.BookPage
|
||||||
|
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.persistence.MediaRepository
|
import org.gotson.komga.domain.persistence.MediaRepository
|
||||||
import org.gotson.komga.jooq.Tables
|
import org.gotson.komga.jooq.Tables
|
||||||
|
|
@ -94,8 +95,10 @@ class MediaDao(
|
||||||
p.BOOK_ID,
|
p.BOOK_ID,
|
||||||
p.FILE_NAME,
|
p.FILE_NAME,
|
||||||
p.MEDIA_TYPE,
|
p.MEDIA_TYPE,
|
||||||
p.NUMBER
|
p.NUMBER,
|
||||||
).values(null as String?, null, null, null)
|
p.WIDTH,
|
||||||
|
p.HEIGHT
|
||||||
|
).values(null as String?, null, null, null, null, null)
|
||||||
).also {
|
).also {
|
||||||
medias.forEach { media ->
|
medias.forEach { media ->
|
||||||
media.pages.forEachIndexed { index, page ->
|
media.pages.forEachIndexed { index, page ->
|
||||||
|
|
@ -103,7 +106,9 @@ class MediaDao(
|
||||||
media.bookId,
|
media.bookId,
|
||||||
page.fileName,
|
page.fileName,
|
||||||
page.mediaType,
|
page.mediaType,
|
||||||
index
|
index,
|
||||||
|
page.dimension?.width,
|
||||||
|
page.dimension?.height
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -198,6 +203,7 @@ class MediaDao(
|
||||||
private fun MediaPageRecord.toDomain() =
|
private fun MediaPageRecord.toDomain() =
|
||||||
BookPage(
|
BookPage(
|
||||||
fileName = fileName,
|
fileName = fileName,
|
||||||
mediaType = mediaType
|
mediaType = mediaType,
|
||||||
|
dimension = if (width != null && height != null) Dimension(width, height) else null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import mu.KotlinLogging
|
||||||
import org.apache.commons.compress.archivers.zip.ZipFile
|
import org.apache.commons.compress.archivers.zip.ZipFile
|
||||||
import org.apache.commons.io.FilenameUtils
|
import org.apache.commons.io.FilenameUtils
|
||||||
import org.gotson.komga.domain.model.MediaContainerEntry
|
import org.gotson.komga.domain.model.MediaContainerEntry
|
||||||
|
import org.gotson.komga.infrastructure.image.ImageAnalyzer
|
||||||
import org.jsoup.Jsoup
|
import org.jsoup.Jsoup
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
|
@ -12,7 +13,11 @@ import java.nio.file.Paths
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class EpubExtractor(contentDetector: ContentDetector) : ZipExtractor(contentDetector) {
|
class EpubExtractor(
|
||||||
|
private val zipExtractor: ZipExtractor,
|
||||||
|
private val contentDetector: ContentDetector,
|
||||||
|
private val imageAnalyzer: ImageAnalyzer
|
||||||
|
) : MediaContainerExtractor {
|
||||||
|
|
||||||
override fun mediaTypes(): List<String> = listOf("application/epub+zip")
|
override fun mediaTypes(): List<String> = listOf("application/epub+zip")
|
||||||
|
|
||||||
|
|
@ -41,17 +46,27 @@ class EpubExtractor(contentDetector: ContentDetector) : ZipExtractor(contentDete
|
||||||
}
|
}
|
||||||
|
|
||||||
return images.map { image ->
|
return images.map { image ->
|
||||||
MediaContainerEntry(image.separatorsToUnix(), manifest.values.first {
|
val name = image.separatorsToUnix()
|
||||||
|
val mediaType = manifest.values.first {
|
||||||
it.href == (opfDir?.relativize(image) ?: image).separatorsToUnix()
|
it.href == (opfDir?.relativize(image) ?: image).separatorsToUnix()
|
||||||
}.mediaType)
|
}.mediaType
|
||||||
|
val dimension = if (contentDetector.isImage(mediaType))
|
||||||
|
imageAnalyzer.getDimension(zip.getInputStream(zip.getEntry(name)))
|
||||||
|
else
|
||||||
|
null
|
||||||
|
MediaContainerEntry(name = name, mediaType = mediaType, dimension = dimension)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error(e) { "File is not a proper Epub, treating it as a zip file" }
|
logger.error(e) { "File is not a proper Epub, treating it as a zip file" }
|
||||||
return super.getEntries(path)
|
return zipExtractor.getEntries(path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getEntryStream(path: Path, entryName: String): ByteArray {
|
||||||
|
return zipExtractor.getEntryStream(path, entryName)
|
||||||
|
}
|
||||||
|
|
||||||
private fun getPackagePath(zip: ZipFile): String =
|
private fun getPackagePath(zip: ZipFile): String =
|
||||||
zip.getEntry("META-INF/container.xml").let { entry ->
|
zip.getEntry("META-INF/container.xml").let { entry ->
|
||||||
val container = zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
|
val container = zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,25 @@
|
||||||
package org.gotson.komga.infrastructure.mediacontainer
|
package org.gotson.komga.infrastructure.mediacontainer
|
||||||
|
|
||||||
|
import mu.KotlinLogging
|
||||||
import org.apache.pdfbox.pdmodel.PDDocument
|
import org.apache.pdfbox.pdmodel.PDDocument
|
||||||
|
import org.apache.pdfbox.pdmodel.PDPage
|
||||||
import org.apache.pdfbox.rendering.ImageType
|
import org.apache.pdfbox.rendering.ImageType
|
||||||
import org.apache.pdfbox.rendering.PDFRenderer
|
import org.apache.pdfbox.rendering.PDFRenderer
|
||||||
|
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.infrastructure.image.ImageAnalyzer
|
||||||
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
|
||||||
import javax.imageio.ImageIO
|
import javax.imageio.ImageIO
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class PdfExtractor : MediaContainerExtractor {
|
class PdfExtractor(
|
||||||
|
private val imageAnalyzer: ImageAnalyzer
|
||||||
|
) : MediaContainerExtractor {
|
||||||
|
|
||||||
private val mediaType = "image/jpeg"
|
private val mediaType = "image/jpeg"
|
||||||
private val imageIOFormat = "jpeg"
|
private val imageIOFormat = "jpeg"
|
||||||
|
|
@ -21,7 +30,10 @@ class PdfExtractor : MediaContainerExtractor {
|
||||||
override fun getEntries(path: Path): List<MediaContainerEntry> =
|
override fun getEntries(path: Path): List<MediaContainerEntry> =
|
||||||
PDDocument.load(path.toFile()).use { pdf ->
|
PDDocument.load(path.toFile()).use { pdf ->
|
||||||
(0 until pdf.numberOfPages).map { index ->
|
(0 until pdf.numberOfPages).map { index ->
|
||||||
MediaContainerEntry(index.toString(), mediaType)
|
val page = pdf.getPage(index)
|
||||||
|
val scale = page.getScale()
|
||||||
|
val dimension = Dimension((page.cropBox.width * scale).roundToInt(), (page.cropBox.height * scale).roundToInt())
|
||||||
|
MediaContainerEntry(name = index.toString(), mediaType = mediaType, dimension = dimension)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,11 +41,12 @@ class PdfExtractor : MediaContainerExtractor {
|
||||||
PDDocument.load(path.toFile()).use { pdf ->
|
PDDocument.load(path.toFile()).use { pdf ->
|
||||||
val pageNumber = entryName.toInt()
|
val pageNumber = entryName.toInt()
|
||||||
val page = pdf.getPage(pageNumber)
|
val page = pdf.getPage(pageNumber)
|
||||||
val scale = resolution / minOf(page.cropBox.width, page.cropBox.height)
|
val image = PDFRenderer(pdf).renderImage(pageNumber, page.getScale(), ImageType.RGB)
|
||||||
val image = PDFRenderer(pdf).renderImage(pageNumber, scale, ImageType.RGB)
|
|
||||||
ByteArrayOutputStream().use { out ->
|
ByteArrayOutputStream().use { out ->
|
||||||
ImageIO.write(image, imageIOFormat, out)
|
ImageIO.write(image, imageIOFormat, out)
|
||||||
out.toByteArray()
|
out.toByteArray()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun PDPage.getScale() = resolution / minOf(cropBox.width, cropBox.height)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,13 @@ package org.gotson.komga.infrastructure.mediacontainer
|
||||||
import com.github.junrar.Archive
|
import com.github.junrar.Archive
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
|
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
|
||||||
|
import org.apache.commons.io.input.TeeInputStream
|
||||||
import org.gotson.komga.domain.model.MediaContainerEntry
|
import org.gotson.komga.domain.model.MediaContainerEntry
|
||||||
import org.gotson.komga.domain.model.MediaUnsupportedException
|
import org.gotson.komga.domain.model.MediaUnsupportedException
|
||||||
|
import org.gotson.komga.infrastructure.image.ImageAnalyzer
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.util.Comparator
|
import java.util.Comparator
|
||||||
|
|
||||||
|
|
@ -13,7 +17,8 @@ private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class RarExtractor(
|
class RarExtractor(
|
||||||
private val contentDetector: ContentDetector
|
private val contentDetector: ContentDetector,
|
||||||
|
private val imageAnalyzer: ImageAnalyzer
|
||||||
) : MediaContainerExtractor {
|
) : MediaContainerExtractor {
|
||||||
|
|
||||||
private val natSortComparator: Comparator<String> = CaseInsensitiveSimpleNaturalComparator.getInstance()
|
private val natSortComparator: Comparator<String> = CaseInsensitiveSimpleNaturalComparator.getInstance()
|
||||||
|
|
@ -27,12 +32,20 @@ class RarExtractor(
|
||||||
if (rar.mainHeader.isMultiVolume) throw MediaUnsupportedException("Multi-Volume RAR archives are not supported")
|
if (rar.mainHeader.isMultiVolume) throw MediaUnsupportedException("Multi-Volume RAR archives are not supported")
|
||||||
rar.fileHeaders
|
rar.fileHeaders
|
||||||
.filter { !it.isDirectory }
|
.filter { !it.isDirectory }
|
||||||
.map {
|
.map { hd ->
|
||||||
try {
|
try {
|
||||||
MediaContainerEntry(name = it.fileName, mediaType = contentDetector.detectMediaType(rar.getInputStream(it)))
|
val buffer = ByteArrayOutputStream()
|
||||||
|
TeeInputStream(rar.getInputStream(hd), buffer).use { tee ->
|
||||||
|
val mediaType = contentDetector.detectMediaType(tee)
|
||||||
|
val dimension = if (contentDetector.isImage(mediaType))
|
||||||
|
imageAnalyzer.getDimension(ByteArrayInputStream(buffer.toByteArray()))
|
||||||
|
else
|
||||||
|
null
|
||||||
|
MediaContainerEntry(name = hd.fileName, mediaType = mediaType, dimension = dimension)
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.warn(e) { "Could not analyze entry: ${it.fileName}" }
|
logger.warn(e) { "Could not analyze entry: ${hd.fileName}" }
|
||||||
MediaContainerEntry(name = it.fileName, comment = e.message)
|
MediaContainerEntry(name = hd.fileName, comment = e.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sortedWith(compareBy(natSortComparator) { it.name })
|
.sortedWith(compareBy(natSortComparator) { it.name })
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,17 @@ import mu.KotlinLogging
|
||||||
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
|
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
|
||||||
import org.apache.commons.compress.archivers.zip.ZipFile
|
import org.apache.commons.compress.archivers.zip.ZipFile
|
||||||
import org.gotson.komga.domain.model.MediaContainerEntry
|
import org.gotson.komga.domain.model.MediaContainerEntry
|
||||||
|
import org.gotson.komga.infrastructure.image.ImageAnalyzer
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.util.*
|
import java.util.Comparator
|
||||||
|
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class ZipExtractor(
|
class ZipExtractor(
|
||||||
private val contentDetector: ContentDetector
|
private val contentDetector: ContentDetector,
|
||||||
|
private val imageAnalyzer: ImageAnalyzer
|
||||||
) : MediaContainerExtractor {
|
) : MediaContainerExtractor {
|
||||||
|
|
||||||
private val natSortComparator: Comparator<String> = CaseInsensitiveSimpleNaturalComparator.getInstance()
|
private val natSortComparator: Comparator<String> = CaseInsensitiveSimpleNaturalComparator.getInstance()
|
||||||
|
|
@ -25,7 +27,12 @@ class ZipExtractor(
|
||||||
.filter { !it.isDirectory }
|
.filter { !it.isDirectory }
|
||||||
.map {
|
.map {
|
||||||
try {
|
try {
|
||||||
MediaContainerEntry(name = it.name, mediaType = contentDetector.detectMediaType(zip.getInputStream(it)))
|
val mediaType = contentDetector.detectMediaType(zip.getInputStream(it))
|
||||||
|
val dimension = if (contentDetector.isImage(mediaType))
|
||||||
|
imageAnalyzer.getDimension(zip.getInputStream(it))
|
||||||
|
else
|
||||||
|
null
|
||||||
|
MediaContainerEntry(name = it.name, mediaType = mediaType, dimension = dimension)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.warn(e) { "Could not analyze entry: ${it.name}" }
|
logger.warn(e) { "Could not analyze entry: ${it.name}" }
|
||||||
MediaContainerEntry(name = it.name, comment = e.message)
|
MediaContainerEntry(name = it.name, comment = e.message)
|
||||||
|
|
|
||||||
|
|
@ -248,7 +248,9 @@ class BookController(
|
||||||
Media.Status.OUTDATED -> throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book is outdated and must be re-analyzed")
|
Media.Status.OUTDATED -> throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book is outdated and must be re-analyzed")
|
||||||
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, s -> PageDto(index + 1, s.fileName, s.mediaType) }
|
Media.Status.READY -> media.pages.mapIndexed { index, s ->
|
||||||
|
PageDto(index + 1, s.fileName, s.mediaType, s.dimension?.width, s.dimension?.height)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,7 @@ package org.gotson.komga.interfaces.rest.dto
|
||||||
data class PageDto(
|
data class PageDto(
|
||||||
val number: Int,
|
val number: Int,
|
||||||
val fileName: String,
|
val fileName: String,
|
||||||
val mediaType: String
|
val mediaType: String,
|
||||||
|
val width: Int?,
|
||||||
|
val height: Int?
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue