mirror of
https://github.com/gotson/komga.git
synced 2025-01-09 04:08:00 +08:00
parent
dedb01fe08
commit
a7252f8429
@ -0,0 +1,16 @@
|
||||
alter table MEDIA_FILE
|
||||
add column MEDIA_TYPE varchar NULL;
|
||||
alter table MEDIA_FILE
|
||||
add column SUB_TYPE varchar NULL;
|
||||
alter table MEDIA_FILE
|
||||
add column FILE_SIZE int8 NULL;
|
||||
|
||||
alter table MEDIA
|
||||
add column EXTENSION_CLASS varchar NULL;
|
||||
alter table MEDIA
|
||||
add column EXTENSION_VALUE varchar NULL;
|
||||
|
||||
update media
|
||||
set STATUS = 'OUTDATED'
|
||||
where MEDIA_TYPE = 'application/epub+zip'
|
||||
and STATUS = 'READY';
|
@ -0,0 +1,7 @@
|
||||
package org.gotson.komga.domain.model
|
||||
|
||||
data class EpubTocEntry(
|
||||
val title: String,
|
||||
val href: String?,
|
||||
val children: List<EpubTocEntry> = emptyList(),
|
||||
)
|
@ -7,8 +7,9 @@ data class Media(
|
||||
val mediaType: String? = null,
|
||||
val pages: List<BookPage> = emptyList(),
|
||||
val pageCount: Int = pages.size,
|
||||
val files: List<String> = emptyList(),
|
||||
val files: List<MediaFile> = emptyList(),
|
||||
val comment: String? = null,
|
||||
val extension: MediaExtension? = null,
|
||||
val bookId: String = "",
|
||||
override val createdDate: LocalDateTime = LocalDateTime.now(),
|
||||
override val lastModifiedDate: LocalDateTime = createdDate,
|
||||
|
@ -0,0 +1,9 @@
|
||||
package org.gotson.komga.domain.model
|
||||
|
||||
interface MediaExtension
|
||||
|
||||
data class MediaExtensionEpub(
|
||||
val toc: List<EpubTocEntry> = emptyList(),
|
||||
val landmarks: List<EpubTocEntry> = emptyList(),
|
||||
val pageList: List<EpubTocEntry> = emptyList(),
|
||||
) : MediaExtension
|
@ -0,0 +1,12 @@
|
||||
package org.gotson.komga.domain.model
|
||||
|
||||
data class MediaFile(
|
||||
val fileName: String,
|
||||
val mediaType: String? = null,
|
||||
val subType: SubType? = null,
|
||||
val fileSize: Long? = null,
|
||||
) {
|
||||
enum class SubType {
|
||||
EPUB_PAGE, EPUB_ASSET
|
||||
}
|
||||
}
|
@ -3,4 +3,5 @@ package org.gotson.komga.domain.model
|
||||
enum class MediaProfile {
|
||||
DIVINA,
|
||||
PDF,
|
||||
EPUB,
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ enum class MediaType(val type: String, val profile: MediaProfile, val fileExtens
|
||||
ZIP("application/zip", MediaProfile.DIVINA, "cbz", "application/vnd.comicbook+zip"),
|
||||
RAR_GENERIC("application/x-rar-compressed", MediaProfile.DIVINA, "cbr", "application/vnd.comicbook-rar"),
|
||||
RAR_4("application/x-rar-compressed; version=4", MediaProfile.DIVINA, "cbr", "application/vnd.comicbook-rar"),
|
||||
EPUB("application/epub+zip", MediaProfile.DIVINA, "epub"),
|
||||
EPUB("application/epub+zip", MediaProfile.EPUB, "epub"),
|
||||
PDF("application/pdf", MediaProfile.PDF, "pdf"),
|
||||
;
|
||||
|
||||
|
@ -7,6 +7,8 @@ import org.gotson.komga.domain.model.BookPageContent
|
||||
import org.gotson.komga.domain.model.BookWithMedia
|
||||
import org.gotson.komga.domain.model.Dimension
|
||||
import org.gotson.komga.domain.model.Media
|
||||
import org.gotson.komga.domain.model.MediaExtensionEpub
|
||||
import org.gotson.komga.domain.model.MediaFile
|
||||
import org.gotson.komga.domain.model.MediaNotReadyException
|
||||
import org.gotson.komga.domain.model.MediaProfile
|
||||
import org.gotson.komga.domain.model.MediaType
|
||||
@ -18,9 +20,9 @@ import org.gotson.komga.infrastructure.image.ImageAnalyzer
|
||||
import org.gotson.komga.infrastructure.image.ImageConverter
|
||||
import org.gotson.komga.infrastructure.image.ImageType
|
||||
import org.gotson.komga.infrastructure.mediacontainer.ContentDetector
|
||||
import org.gotson.komga.infrastructure.mediacontainer.CoverExtractor
|
||||
import org.gotson.komga.infrastructure.mediacontainer.MediaContainerExtractor
|
||||
import org.gotson.komga.infrastructure.mediacontainer.PdfExtractor
|
||||
import org.gotson.komga.infrastructure.mediacontainer.divina.DivinaExtractor
|
||||
import org.gotson.komga.infrastructure.mediacontainer.epub.EpubExtractor
|
||||
import org.gotson.komga.infrastructure.mediacontainer.pdf.PdfExtractor
|
||||
import org.springframework.beans.factory.annotation.Qualifier
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
@ -34,8 +36,9 @@ private val logger = KotlinLogging.logger {}
|
||||
@Service
|
||||
class BookAnalyzer(
|
||||
private val contentDetector: ContentDetector,
|
||||
extractors: List<MediaContainerExtractor>,
|
||||
extractors: List<DivinaExtractor>,
|
||||
private val pdfExtractor: PdfExtractor,
|
||||
private val epubExtractor: EpubExtractor,
|
||||
private val imageConverter: ImageConverter,
|
||||
private val imageAnalyzer: ImageAnalyzer,
|
||||
private val hasher: Hasher,
|
||||
@ -47,67 +50,80 @@ class BookAnalyzer(
|
||||
private val pdfImageType: ImageType,
|
||||
) {
|
||||
|
||||
val supportedMediaTypes = extractors
|
||||
val divinaExtractors = extractors
|
||||
.flatMap { e -> e.mediaTypes().map { it to e } }
|
||||
.toMap()
|
||||
|
||||
fun analyze(book: Book, analyzeDimensions: Boolean): Media {
|
||||
logger.info { "Trying to analyze book: $book" }
|
||||
try {
|
||||
return try {
|
||||
val mediaType = contentDetector.detectMediaType(book.path).let {
|
||||
logger.info { "Detected media type: $it" }
|
||||
MediaType.fromMediaType(it) ?: return Media(mediaType = it, status = Media.Status.UNSUPPORTED, comment = "ERR_1001", bookId = book.id)
|
||||
}
|
||||
|
||||
if (mediaType.profile == MediaProfile.PDF) {
|
||||
val pages = pdfExtractor.getPages(book.path, analyzeDimensions).map { BookPage(it.name, "", it.dimension) }
|
||||
return Media(mediaType = mediaType.type, status = Media.Status.READY, pages = pages, bookId = book.id)
|
||||
}
|
||||
|
||||
val entries = try {
|
||||
supportedMediaTypes.getValue(mediaType.type).getEntries(book.path, analyzeDimensions)
|
||||
} catch (ex: MediaUnsupportedException) {
|
||||
return Media(mediaType = mediaType.type, status = Media.Status.UNSUPPORTED, comment = ex.code, bookId = book.id)
|
||||
} catch (ex: Exception) {
|
||||
logger.error(ex) { "Error while analyzing book: $book" }
|
||||
return Media(mediaType = mediaType.type, status = Media.Status.ERROR, comment = "ERR_1008", bookId = book.id)
|
||||
}
|
||||
|
||||
val (pages, others) = entries
|
||||
.partition { entry ->
|
||||
entry.mediaType?.let { contentDetector.isImage(it) } ?: false
|
||||
}.let { (images, others) ->
|
||||
Pair(
|
||||
images.map { BookPage(fileName = it.name, mediaType = it.mediaType!!, dimension = it.dimension, fileSize = it.fileSize) },
|
||||
others,
|
||||
)
|
||||
}
|
||||
|
||||
val entriesErrorSummary = others
|
||||
.filter { it.mediaType.isNullOrBlank() }
|
||||
.map { it.name }
|
||||
.ifEmpty { null }
|
||||
?.joinToString(prefix = "ERR_1007 [", postfix = "]") { it }
|
||||
|
||||
if (pages.isEmpty()) {
|
||||
logger.warn { "Book $book does not contain any pages" }
|
||||
return Media(mediaType = mediaType.type, status = Media.Status.ERROR, comment = "ERR_1006", bookId = book.id)
|
||||
}
|
||||
logger.info { "Book has ${pages.size} pages" }
|
||||
|
||||
val files = others.map { it.name }
|
||||
|
||||
return Media(mediaType = mediaType.type, status = Media.Status.READY, pages = pages, pageCount = pages.size, files = files, comment = entriesErrorSummary, bookId = book.id)
|
||||
when (mediaType.profile) {
|
||||
MediaProfile.DIVINA -> analyzeDivina(book, mediaType, analyzeDimensions)
|
||||
MediaProfile.PDF -> analyzePdf(book, analyzeDimensions)
|
||||
MediaProfile.EPUB -> analyzeEpub(book)
|
||||
}.copy(mediaType = mediaType.type)
|
||||
} catch (ade: AccessDeniedException) {
|
||||
logger.error(ade) { "Error while analyzing book: $book" }
|
||||
return Media(status = Media.Status.ERROR, comment = "ERR_1000", bookId = book.id)
|
||||
Media(status = Media.Status.ERROR, comment = "ERR_1000")
|
||||
} catch (ex: NoSuchFileException) {
|
||||
logger.error(ex) { "Error while analyzing book: $book" }
|
||||
return Media(status = Media.Status.ERROR, comment = "ERR_1018", bookId = book.id)
|
||||
Media(status = Media.Status.ERROR, comment = "ERR_1018")
|
||||
} catch (ex: Exception) {
|
||||
logger.error(ex) { "Error while analyzing book: $book" }
|
||||
return Media(status = Media.Status.ERROR, comment = "ERR_1005", bookId = book.id)
|
||||
Media(status = Media.Status.ERROR, comment = "ERR_1005")
|
||||
}.copy(bookId = book.id)
|
||||
}
|
||||
|
||||
private fun analyzeDivina(book: Book, mediaType: MediaType, analyzeDimensions: Boolean): Media {
|
||||
val entries = try {
|
||||
divinaExtractors.getValue(mediaType.type).getEntries(book.path, analyzeDimensions)
|
||||
} catch (ex: MediaUnsupportedException) {
|
||||
return Media(status = Media.Status.UNSUPPORTED, comment = ex.code)
|
||||
} catch (ex: Exception) {
|
||||
logger.error(ex) { "Error while analyzing book: $book" }
|
||||
return Media(status = Media.Status.ERROR, comment = "ERR_1008")
|
||||
}
|
||||
|
||||
val (pages, others) = entries
|
||||
.partition { entry ->
|
||||
entry.mediaType?.let { contentDetector.isImage(it) } ?: false
|
||||
}.let { (images, others) ->
|
||||
Pair(
|
||||
images.map { BookPage(fileName = it.name, mediaType = it.mediaType!!, dimension = it.dimension, fileSize = it.fileSize) },
|
||||
others,
|
||||
)
|
||||
}
|
||||
|
||||
val entriesErrorSummary = others
|
||||
.filter { it.mediaType.isNullOrBlank() }
|
||||
.map { it.name }
|
||||
.ifEmpty { null }
|
||||
?.joinToString(prefix = "ERR_1007 [", postfix = "]") { it }
|
||||
|
||||
if (pages.isEmpty()) {
|
||||
logger.warn { "Book $book does not contain any pages" }
|
||||
return Media(status = Media.Status.ERROR, comment = "ERR_1006")
|
||||
}
|
||||
logger.info { "Book has ${pages.size} pages" }
|
||||
|
||||
val files = others.map { MediaFile(fileName = it.name, mediaType = it.mediaType, fileSize = it.fileSize) }
|
||||
|
||||
return Media(status = Media.Status.READY, pages = pages, pageCount = pages.size, files = files, comment = entriesErrorSummary)
|
||||
}
|
||||
|
||||
private fun analyzeEpub(book: Book): Media {
|
||||
val manifest = epubExtractor.getManifest(book.path)
|
||||
return Media(status = Media.Status.READY, files = manifest.resources, pageCount = manifest.pageCount, extension = MediaExtensionEpub(toc = manifest.toc, landmarks = manifest.landmarks, pageList = manifest.pageList))
|
||||
}
|
||||
|
||||
private fun analyzePdf(book: Book, analyzeDimensions: Boolean): Media {
|
||||
val pages = pdfExtractor.getPages(book.path, analyzeDimensions).map { BookPage(it.name, "", it.dimension) }
|
||||
return Media(status = Media.Status.READY, pages = pages)
|
||||
}
|
||||
|
||||
@Throws(MediaNotReadyException::class)
|
||||
@ -120,23 +136,12 @@ class BookAnalyzer(
|
||||
}
|
||||
|
||||
val thumbnail = try {
|
||||
val extractor = supportedMediaTypes[book.media.mediaType!!]
|
||||
// try to get the cover from a CoverExtractor first
|
||||
var coverBytes: ByteArray? = if (extractor is CoverExtractor) {
|
||||
try {
|
||||
extractor.getCoverStream(book.book.path)
|
||||
} catch (e: Exception) {
|
||||
logger.warn(e) { "Error while extracting cover. Falling back to first page. Book: $book" }
|
||||
null
|
||||
}
|
||||
} else null
|
||||
// if no cover could be found, get the first page
|
||||
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 ->
|
||||
when (book.media.profile) {
|
||||
MediaProfile.DIVINA -> divinaExtractors[book.media.mediaType]?.getEntryStream(book.book.path, book.media.pages.first().fileName)
|
||||
MediaProfile.PDF -> pdfExtractor.getPageContentAsImage(book.book.path, 1).content
|
||||
MediaProfile.EPUB -> epubExtractor.getCover(book.book.path)?.content
|
||||
null -> null
|
||||
}?.let { cover ->
|
||||
imageConverter.resizeImageToByteArray(cover, thumbnailType, komgaSettingsProvider.thumbnailSize.maxEdge)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
@ -172,8 +177,9 @@ class BookAnalyzer(
|
||||
}
|
||||
|
||||
return when (book.media.profile) {
|
||||
MediaProfile.DIVINA -> supportedMediaTypes.getValue(book.media.mediaType!!).getEntryStream(book.book.path, book.media.pages[number - 1].fileName)
|
||||
MediaProfile.DIVINA -> divinaExtractors.getValue(book.media.mediaType!!).getEntryStream(book.book.path, book.media.pages[number - 1].fileName)
|
||||
MediaProfile.PDF -> pdfExtractor.getPageContentAsImage(book.book.path, number).content
|
||||
MediaProfile.EPUB -> throw MediaUnsupportedException("Epub profile does not support getting page content")
|
||||
null -> throw MediaNotReadyException()
|
||||
}
|
||||
}
|
||||
@ -184,6 +190,7 @@ class BookAnalyzer(
|
||||
)
|
||||
fun getPageContentRaw(book: BookWithMedia, number: Int): BookPageContent {
|
||||
logger.debug { "Get raw page #$number for book: $book" }
|
||||
if (book.media.profile != MediaProfile.PDF) throw MediaUnsupportedException("Extractor does not support raw extraction of pages")
|
||||
|
||||
if (book.media.status != Media.Status.READY) {
|
||||
logger.warn { "Book media is not ready, cannot get pages" }
|
||||
@ -195,8 +202,6 @@ class BookAnalyzer(
|
||||
throw IndexOutOfBoundsException("Page $number does not exist")
|
||||
}
|
||||
|
||||
if (book.media.profile != MediaProfile.PDF) throw MediaUnsupportedException("Extractor does not support raw extraction of pages")
|
||||
|
||||
return pdfExtractor.getPageContentAsPdf(book.book.path, number)
|
||||
}
|
||||
|
||||
@ -211,9 +216,11 @@ class BookAnalyzer(
|
||||
throw MediaNotReadyException()
|
||||
}
|
||||
|
||||
if (book.media.profile != MediaProfile.DIVINA) throw MediaUnsupportedException("Extractor does not support extraction of files")
|
||||
|
||||
return supportedMediaTypes.getValue(book.media.mediaType!!).getEntryStream(book.book.path, fileName)
|
||||
return when (book.media.profile) {
|
||||
MediaProfile.DIVINA -> divinaExtractors.getValue(book.media.mediaType!!).getEntryStream(book.book.path, fileName)
|
||||
MediaProfile.EPUB -> epubExtractor.getEntryStream(book.book.path, fileName)
|
||||
MediaProfile.PDF, null -> throw MediaUnsupportedException("Extractor does not support extraction of files")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -102,7 +102,7 @@ class BookConverter(
|
||||
|
||||
media
|
||||
.pages.map { it.fileName }
|
||||
.union(media.files)
|
||||
.union(media.files.map { it.fileName })
|
||||
.forEach { entry ->
|
||||
zipStream.putArchiveEntry(ZipArchiveEntry(entry))
|
||||
zipStream.write(bookAnalyzer.getFileContent(BookWithMedia(book, media), entry))
|
||||
@ -133,8 +133,8 @@ class BookConverter(
|
||||
.containsAll(media.pages.map { FilenameUtils.getName(it.fileName) to it.mediaType })
|
||||
-> throw BookConversionException("Converted file does not contain all pages from existing file, aborting conversion")
|
||||
|
||||
!convertedMedia.files.map { FilenameUtils.getName(it) }
|
||||
.containsAll(media.files.map { FilenameUtils.getName(it) })
|
||||
!convertedMedia.files.map { FilenameUtils.getName(it.fileName) }
|
||||
.containsAll(media.files.map { FilenameUtils.getName(it.fileName) })
|
||||
-> throw BookConversionException("Converted file does not contain all files from existing file, aborting conversion")
|
||||
}
|
||||
} catch (e: BookConversionException) {
|
||||
|
@ -100,7 +100,7 @@ class BookPageEditor(
|
||||
zipStream.setLevel(Deflater.NO_COMPRESSION)
|
||||
|
||||
pagesToKeep.map { it.fileName }
|
||||
.union(media.files)
|
||||
.union(media.files.map { it.fileName })
|
||||
.forEach { entry ->
|
||||
zipStream.putArchiveEntry(ZipArchiveEntry(entry))
|
||||
zipStream.write(bookAnalyzer.getFileContent(BookWithMedia(book, media), entry))
|
||||
@ -131,8 +131,8 @@ class BookPageEditor(
|
||||
.containsAll(pagesToKeep.map { FilenameUtils.getName(it.fileName) to it.mediaType })
|
||||
-> throw BookConversionException("Created file does not contain all pages to keep from existing file, aborting conversion")
|
||||
|
||||
!createdMedia.files.map { FilenameUtils.getName(it) }
|
||||
.containsAll(media.files.map { FilenameUtils.getName(it) })
|
||||
!createdMedia.files.map { FilenameUtils.getName(it.fileName) }
|
||||
.containsAll(media.files.map { FilenameUtils.getName(it.fileName) })
|
||||
-> throw BookConversionException("Created file does not contain all files from existing file, aborting page removal")
|
||||
}
|
||||
} catch (e: BookConversionException) {
|
||||
|
@ -1,13 +1,18 @@
|
||||
package org.gotson.komga.infrastructure.jooq.main
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import mu.KotlinLogging
|
||||
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.MediaExtension
|
||||
import org.gotson.komga.domain.model.MediaFile
|
||||
import org.gotson.komga.domain.persistence.MediaRepository
|
||||
import org.gotson.komga.infrastructure.jooq.insertTempStrings
|
||||
import org.gotson.komga.infrastructure.jooq.selectTempStrings
|
||||
import org.gotson.komga.infrastructure.jooq.toCurrentTimeZone
|
||||
import org.gotson.komga.jooq.main.Tables
|
||||
import org.gotson.komga.jooq.main.tables.records.MediaFileRecord
|
||||
import org.gotson.komga.jooq.main.tables.records.MediaPageRecord
|
||||
import org.gotson.komga.jooq.main.tables.records.MediaRecord
|
||||
import org.jooq.DSLContext
|
||||
@ -18,10 +23,13 @@ import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
@Component
|
||||
class MediaDao(
|
||||
private val dsl: DSLContext,
|
||||
@Value("#{@komgaProperties.database.batchChunkSize}") private val batchSize: Int,
|
||||
private val mapper: ObjectMapper,
|
||||
) : MediaRepository {
|
||||
|
||||
private val m = Tables.MEDIA
|
||||
@ -84,9 +92,8 @@ class MediaDao(
|
||||
val files = dsl.selectFrom(f)
|
||||
.where(f.BOOK_ID.eq(bookId))
|
||||
.fetchInto(f)
|
||||
.map { it.fileName }
|
||||
|
||||
mr.toDomain(pr.filterNot { it.bookId == null }.map { it.toDomain() }, files)
|
||||
mr.toDomain(pr.filterNot { it.bookId == null }.map { it.toDomain() }, files.map { it.toDomain() })
|
||||
}.firstOrNull()
|
||||
|
||||
@Transactional
|
||||
@ -106,15 +113,19 @@ class MediaDao(
|
||||
m.MEDIA_TYPE,
|
||||
m.COMMENT,
|
||||
m.PAGE_COUNT,
|
||||
).values(null as String?, null, null, null, null),
|
||||
m.EXTENSION_CLASS,
|
||||
m.EXTENSION_VALUE,
|
||||
).values(null as String?, null, null, null, null, null, null),
|
||||
).also { step ->
|
||||
chunk.forEach {
|
||||
chunk.forEach { media ->
|
||||
step.bind(
|
||||
it.bookId,
|
||||
it.status,
|
||||
it.mediaType,
|
||||
it.comment,
|
||||
it.pageCount,
|
||||
media.bookId,
|
||||
media.status,
|
||||
media.mediaType,
|
||||
media.comment,
|
||||
media.pageCount,
|
||||
media.extension?.let { it::class.qualifiedName },
|
||||
media.extension?.let { mapper.writeValueAsString(it) },
|
||||
)
|
||||
}
|
||||
}.execute()
|
||||
@ -168,13 +179,19 @@ class MediaDao(
|
||||
f,
|
||||
f.BOOK_ID,
|
||||
f.FILE_NAME,
|
||||
).values(null as String?, null),
|
||||
f.MEDIA_TYPE,
|
||||
f.SUB_TYPE,
|
||||
f.FILE_SIZE,
|
||||
).values(null as String?, null, null, null, null),
|
||||
).also { step ->
|
||||
chunk.forEach { media ->
|
||||
media.files.forEach {
|
||||
step.bind(
|
||||
media.bookId,
|
||||
it,
|
||||
it.fileName,
|
||||
it.mediaType,
|
||||
it.subType,
|
||||
it.fileSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -190,6 +207,8 @@ class MediaDao(
|
||||
.set(m.MEDIA_TYPE, media.mediaType)
|
||||
.set(m.COMMENT, media.comment)
|
||||
.set(m.PAGE_COUNT, media.pageCount)
|
||||
.set(m.EXTENSION_CLASS, media.extension?.let { it::class.qualifiedName })
|
||||
.set(m.EXTENSION_VALUE, media.extension?.let { mapper.writeValueAsString(it) })
|
||||
.set(m.LAST_MODIFIED_DATE, LocalDateTime.now(ZoneId.of("Z")))
|
||||
.where(m.BOOK_ID.eq(media.bookId))
|
||||
.execute()
|
||||
@ -224,19 +243,30 @@ class MediaDao(
|
||||
|
||||
override fun count(): Long = dsl.fetchCount(m).toLong()
|
||||
|
||||
private fun MediaRecord.toDomain(pages: List<BookPage>, files: List<String>) =
|
||||
private fun MediaRecord.toDomain(pages: List<BookPage>, files: List<MediaFile>) =
|
||||
Media(
|
||||
status = Media.Status.valueOf(status),
|
||||
mediaType = mediaType,
|
||||
pages = pages,
|
||||
pageCount = pageCount,
|
||||
files = files,
|
||||
extension = deserializeExtension(extensionClass, extensionValue),
|
||||
comment = comment,
|
||||
bookId = bookId,
|
||||
createdDate = createdDate.toCurrentTimeZone(),
|
||||
lastModifiedDate = lastModifiedDate.toCurrentTimeZone(),
|
||||
)
|
||||
|
||||
private fun deserializeExtension(extensionClass: String?, extensionValue: String?): MediaExtension? {
|
||||
if (extensionClass == null && extensionValue == null) return null
|
||||
return try {
|
||||
mapper.readValue(extensionValue, Class.forName(extensionClass)) as MediaExtension
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Could not deserialize media extension class: $extensionClass, value: $extensionValue" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun MediaPageRecord.toDomain() =
|
||||
BookPage(
|
||||
fileName = fileName,
|
||||
@ -245,4 +275,12 @@ class MediaDao(
|
||||
fileHash = fileHash,
|
||||
fileSize = fileSize,
|
||||
)
|
||||
|
||||
private fun MediaFileRecord.toDomain() =
|
||||
MediaFile(
|
||||
fileName = fileName,
|
||||
mediaType = mediaType,
|
||||
subType = subType?.let { MediaFile.SubType.valueOf(it) },
|
||||
fileSize = fileSize,
|
||||
)
|
||||
}
|
||||
|
@ -1,7 +0,0 @@
|
||||
package org.gotson.komga.infrastructure.mediacontainer
|
||||
|
||||
import java.nio.file.Path
|
||||
|
||||
interface CoverExtractor {
|
||||
fun getCoverStream(path: Path): ByteArray?
|
||||
}
|
@ -1,137 +0,0 @@
|
||||
package org.gotson.komga.infrastructure.mediacontainer
|
||||
|
||||
import mu.KotlinLogging
|
||||
import org.apache.commons.compress.archivers.ArchiveEntry
|
||||
import org.apache.commons.compress.archivers.zip.ZipFile
|
||||
import org.gotson.komga.domain.model.MediaContainerEntry
|
||||
import org.gotson.komga.domain.model.MediaType
|
||||
import org.gotson.komga.domain.model.MediaUnsupportedException
|
||||
import org.gotson.komga.infrastructure.image.ImageAnalyzer
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import org.springframework.stereotype.Service
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import kotlin.io.path.invariantSeparatorsPathString
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
@Service
|
||||
class EpubExtractor(
|
||||
private val zipExtractor: ZipExtractor,
|
||||
private val contentDetector: ContentDetector,
|
||||
private val imageAnalyzer: ImageAnalyzer,
|
||||
) : MediaContainerExtractor, CoverExtractor {
|
||||
|
||||
override fun mediaTypes(): List<String> = listOf(MediaType.EPUB.type)
|
||||
|
||||
override fun getEntries(path: Path, analyzeDimensions: Boolean): List<MediaContainerEntry> {
|
||||
ZipFile(path.toFile()).use { zip ->
|
||||
try {
|
||||
val opfFile = getPackagePath(zip)
|
||||
|
||||
val opfDoc = zip.getInputStream(zip.getEntry(opfFile)).use { Jsoup.parse(it, null, "") }
|
||||
val opfDir = Paths.get(opfFile).parent
|
||||
|
||||
val manifest = opfDoc.getManifest()
|
||||
|
||||
val pages = opfDoc.select("spine > itemref").map { it.attr("idref") }
|
||||
.mapNotNull { manifest[it] }
|
||||
.map { it.href }
|
||||
|
||||
val images = pages
|
||||
.map { opfDir?.resolve(it)?.normalize() ?: Paths.get(it) }
|
||||
.flatMap { pagePath ->
|
||||
val doc = zip.getInputStream(zip.getEntry(pagePath.invariantSeparatorsPathString)).use { Jsoup.parse(it, null, "") }
|
||||
|
||||
val img = doc.getElementsByTag("img")
|
||||
.map { it.attr("src") } // get the src, which can be a relative path
|
||||
|
||||
val svg = doc.select("svg > image[xlink:href]")
|
||||
.map { it.attr("xlink:href") } // get the source, which can be a relative path
|
||||
|
||||
(img + svg).map { pagePath.parentOrEmpty().resolve(it).normalize() } // resolve it against the page folder
|
||||
}
|
||||
|
||||
return images.map { image ->
|
||||
val name = image.invariantSeparatorsPathString
|
||||
val mediaType = manifest.values.first {
|
||||
it.href == (opfDir?.relativize(image) ?: image).invariantSeparatorsPathString
|
||||
}.mediaType
|
||||
val zipEntry = zip.getEntry(name)
|
||||
val dimension = if (analyzeDimensions && contentDetector.isImage(mediaType))
|
||||
zip.getInputStream(zipEntry).use { imageAnalyzer.getDimension(it) }
|
||||
else
|
||||
null
|
||||
val fileSize = if (zipEntry.size == ArchiveEntry.SIZE_UNKNOWN) null else zipEntry.size
|
||||
MediaContainerEntry(name = name, mediaType = mediaType, dimension = dimension, fileSize = fileSize)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "File is not a proper Epub, treating it as a zip file" }
|
||||
return zipExtractor.getEntries(path, analyzeDimensions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getEntryStream(path: Path, entryName: String): ByteArray {
|
||||
return zipExtractor.getEntryStream(path, entryName)
|
||||
}
|
||||
|
||||
private fun getPackagePath(zip: ZipFile): String =
|
||||
zip.getEntry("META-INF/container.xml").let { entry ->
|
||||
val container = zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
|
||||
container.getElementsByTag("rootfile").first()?.attr("full-path")
|
||||
?: throw MediaUnsupportedException("META-INF/container.xml does not contain rootfile tag")
|
||||
}
|
||||
|
||||
private fun Document.getManifest() =
|
||||
select("manifest > item")
|
||||
.associate {
|
||||
it.attr("id") to ManifestItem(
|
||||
it.attr("id"),
|
||||
it.attr("href"),
|
||||
it.attr("media-type"),
|
||||
it.attr("properties").split(" ").toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
fun getPackageFile(path: Path): String? =
|
||||
ZipFile(path.toFile()).use { zip ->
|
||||
try {
|
||||
zip.getInputStream(zip.getEntry(getPackagePath(zip))).reader().use { it.readText() }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun Path.parentOrEmpty(): Path = parent ?: Paths.get("")
|
||||
|
||||
private data class ManifestItem(
|
||||
val id: String,
|
||||
val href: String,
|
||||
val mediaType: String,
|
||||
val properties: Set<String> = emptySet(),
|
||||
)
|
||||
|
||||
override fun getCoverStream(path: Path): ByteArray? {
|
||||
ZipFile(path.toFile()).use { zip ->
|
||||
val opfFile = getPackagePath(zip)
|
||||
|
||||
val opfDoc = zip.getInputStream(zip.getEntry(opfFile)).use { Jsoup.parse(it, null, "") }
|
||||
val opfDir = Paths.get(opfFile).parent
|
||||
val manifest = opfDoc.getManifest()
|
||||
|
||||
val coverManifestItem =
|
||||
// EPUB 3 - try to get cover from manifest properties 'cover-image'
|
||||
manifest.values.firstOrNull { it.properties.contains("cover-image") }
|
||||
?: // EPUB 2 - get cover from meta element with name="cover"
|
||||
opfDoc.selectFirst("metadata > meta[name=cover]")?.attr("content")?.ifBlank { null }?.let { manifest[it] }
|
||||
|
||||
if (coverManifestItem != null) {
|
||||
val href = coverManifestItem.href
|
||||
val coverPath = opfDir?.resolve(href)?.normalize() ?: Paths.get(href)
|
||||
return zip.getInputStream(zip.getEntry(coverPath.invariantSeparatorsPathString)).readAllBytes()
|
||||
} else return null
|
||||
}
|
||||
}
|
||||
}
|
@ -1,10 +1,10 @@
|
||||
package org.gotson.komga.infrastructure.mediacontainer
|
||||
package org.gotson.komga.infrastructure.mediacontainer.divina
|
||||
|
||||
import org.gotson.komga.domain.model.MediaContainerEntry
|
||||
import org.gotson.komga.domain.model.MediaUnsupportedException
|
||||
import java.nio.file.Path
|
||||
|
||||
interface MediaContainerExtractor {
|
||||
interface DivinaExtractor {
|
||||
fun mediaTypes(): List<String>
|
||||
|
||||
@Throws(MediaUnsupportedException::class)
|
@ -1,4 +1,4 @@
|
||||
package org.gotson.komga.infrastructure.mediacontainer
|
||||
package org.gotson.komga.infrastructure.mediacontainer.divina
|
||||
|
||||
import com.github.junrar.Archive
|
||||
import mu.KotlinLogging
|
||||
@ -7,6 +7,7 @@ import org.gotson.komga.domain.model.MediaContainerEntry
|
||||
import org.gotson.komga.domain.model.MediaType
|
||||
import org.gotson.komga.domain.model.MediaUnsupportedException
|
||||
import org.gotson.komga.infrastructure.image.ImageAnalyzer
|
||||
import org.gotson.komga.infrastructure.mediacontainer.ContentDetector
|
||||
import org.springframework.stereotype.Service
|
||||
import java.nio.file.Path
|
||||
|
||||
@ -16,7 +17,7 @@ private val logger = KotlinLogging.logger {}
|
||||
class RarExtractor(
|
||||
private val contentDetector: ContentDetector,
|
||||
private val imageAnalyzer: ImageAnalyzer,
|
||||
) : MediaContainerExtractor {
|
||||
) : DivinaExtractor {
|
||||
|
||||
private val natSortComparator: Comparator<String> = CaseInsensitiveSimpleNaturalComparator.getInstance()
|
||||
|
@ -1,4 +1,4 @@
|
||||
package org.gotson.komga.infrastructure.mediacontainer
|
||||
package org.gotson.komga.infrastructure.mediacontainer.divina
|
||||
|
||||
import mu.KotlinLogging
|
||||
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
|
||||
@ -7,6 +7,7 @@ import org.apache.commons.compress.archivers.zip.ZipFile
|
||||
import org.gotson.komga.domain.model.MediaContainerEntry
|
||||
import org.gotson.komga.domain.model.MediaType
|
||||
import org.gotson.komga.infrastructure.image.ImageAnalyzer
|
||||
import org.gotson.komga.infrastructure.mediacontainer.ContentDetector
|
||||
import org.springframework.stereotype.Service
|
||||
import java.nio.file.Path
|
||||
|
||||
@ -16,7 +17,7 @@ private val logger = KotlinLogging.logger {}
|
||||
class ZipExtractor(
|
||||
private val contentDetector: ContentDetector,
|
||||
private val imageAnalyzer: ImageAnalyzer,
|
||||
) : MediaContainerExtractor {
|
||||
) : DivinaExtractor {
|
||||
|
||||
private val natSortComparator: Comparator<String> = CaseInsensitiveSimpleNaturalComparator.getInstance()
|
||||
|
@ -0,0 +1,38 @@
|
||||
package org.gotson.komga.infrastructure.mediacontainer.epub
|
||||
|
||||
import org.apache.commons.compress.archivers.zip.ZipFile
|
||||
import org.gotson.komga.domain.model.MediaUnsupportedException
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
|
||||
data class EpubPackage(
|
||||
val zip: ZipFile,
|
||||
val opfDoc: Document,
|
||||
val opfDir: Path?,
|
||||
val manifest: Map<String, ManifestItem>,
|
||||
)
|
||||
|
||||
inline fun <R> Path.epub(block: (EpubPackage) -> R): R =
|
||||
ZipFile(this.toFile()).use { zip ->
|
||||
val opfFile = zip.getPackagePath()
|
||||
val opfDoc = zip.getInputStream(zip.getEntry(opfFile)).use { Jsoup.parse(it, null, "") }
|
||||
val opfDir = Paths.get(opfFile).parent
|
||||
block(EpubPackage(zip, opfDoc, opfDir, opfDoc.getManifest()))
|
||||
}
|
||||
|
||||
fun ZipFile.getPackagePath(): String =
|
||||
getEntry("META-INF/container.xml").let { entry ->
|
||||
val container = getInputStream(entry).use { Jsoup.parse(it, null, "") }
|
||||
container.getElementsByTag("rootfile").first()?.attr("full-path") ?: throw MediaUnsupportedException("META-INF/container.xml does not contain rootfile tag")
|
||||
}
|
||||
|
||||
fun getPackageFile(path: Path): String? =
|
||||
ZipFile(path.toFile()).use { zip ->
|
||||
try {
|
||||
zip.getInputStream(zip.getEntry(zip.getPackagePath())).reader().use { it.readText() }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
package org.gotson.komga.infrastructure.mediacontainer.epub
|
||||
|
||||
enum class Epub2Nav(val level1: String, val level2: String) {
|
||||
TOC("navMap", "navPoint"),
|
||||
PAGELIST("pageList", "pageTarget"),
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
package org.gotson.komga.infrastructure.mediacontainer.epub
|
||||
|
||||
enum class Epub3Nav(val value: String) {
|
||||
TOC("toc"),
|
||||
LANDMARKS("landmarks"),
|
||||
PAGELIST("page-list"),
|
||||
}
|
@ -0,0 +1,112 @@
|
||||
package org.gotson.komga.infrastructure.mediacontainer.epub
|
||||
|
||||
import org.apache.commons.compress.archivers.ArchiveEntry
|
||||
import org.apache.commons.compress.archivers.zip.ZipFile
|
||||
import org.gotson.komga.domain.model.BookPageContent
|
||||
import org.gotson.komga.domain.model.EpubTocEntry
|
||||
import org.gotson.komga.domain.model.MediaFile
|
||||
import org.springframework.stereotype.Service
|
||||
import java.nio.file.Path
|
||||
import kotlin.math.ceil
|
||||
|
||||
@Service
|
||||
class EpubExtractor {
|
||||
|
||||
/**
|
||||
* Retrieves a specific entry by name from the zip archive
|
||||
*/
|
||||
fun getEntryStream(path: Path, entryName: String): ByteArray =
|
||||
ZipFile(path.toFile()).use { zip ->
|
||||
zip.getInputStream(zip.getEntry(entryName)).use { it.readBytes() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the book cover along with its mediaType from the epub 2/3 manifest
|
||||
*/
|
||||
fun getCover(path: Path): BookPageContent? =
|
||||
path.epub { (zip, opfDoc, opfDir, manifest) ->
|
||||
val coverManifestItem =
|
||||
// EPUB 3 - try to get cover from manifest properties 'cover-image'
|
||||
manifest.values.firstOrNull { it.properties.contains("cover-image") }
|
||||
?: // EPUB 2 - get cover from meta element with name="cover"
|
||||
opfDoc.selectFirst("metadata > meta[name=cover]")?.attr("content")?.ifBlank { null }?.let { manifest[it] }
|
||||
|
||||
if (coverManifestItem != null) {
|
||||
val href = coverManifestItem.href
|
||||
val mediaType = coverManifestItem.mediaType
|
||||
val coverPath = normalizeHref(opfDir, href)
|
||||
BookPageContent(
|
||||
zip.getInputStream(zip.getEntry(coverPath)).readAllBytes(),
|
||||
mediaType,
|
||||
)
|
||||
} else null
|
||||
}
|
||||
|
||||
fun getManifest(path: Path): EpubManifest =
|
||||
path.epub { epub ->
|
||||
EpubManifest(
|
||||
resources = getResources(epub),
|
||||
toc = getToc(epub),
|
||||
landmarks = getLandmarks(epub),
|
||||
pageList = getPageList(epub),
|
||||
pageCount = computePageCount(epub),
|
||||
)
|
||||
}
|
||||
|
||||
private fun getResources(epub: EpubPackage): List<MediaFile> {
|
||||
val spine = epub.opfDoc.select("spine > itemref").map { it.attr("idref") }.mapNotNull { epub.manifest[it] }
|
||||
|
||||
val pages = spine.map { page ->
|
||||
MediaFile(
|
||||
normalizeHref(epub.opfDir, page.href),
|
||||
page.mediaType,
|
||||
MediaFile.SubType.EPUB_PAGE,
|
||||
)
|
||||
}
|
||||
|
||||
val assets = epub.manifest.values.filterNot { spine.contains(it) }.map {
|
||||
MediaFile(
|
||||
normalizeHref(epub.opfDir, it.href),
|
||||
it.mediaType,
|
||||
MediaFile.SubType.EPUB_ASSET,
|
||||
)
|
||||
}
|
||||
|
||||
val zipEntries = epub.zip.entries.toList()
|
||||
return (pages + assets).map { resource ->
|
||||
resource.copy(fileSize = zipEntries.firstOrNull { it.name == resource.fileName }?.let { if (it.size == ArchiveEntry.SIZE_UNKNOWN) null else it.size })
|
||||
}
|
||||
}
|
||||
|
||||
private fun computePageCount(epub: EpubPackage): Int {
|
||||
val spine = epub.opfDoc.select("spine > itemref")
|
||||
.map { it.attr("idref") }
|
||||
.mapNotNull { idref -> epub.manifest[idref]?.href?.let { normalizeHref(epub.opfDir, it) } }
|
||||
|
||||
return epub.zip.entries.toList().filter { it.name in spine }.sumOf { ceil(it.compressedSize / 1024.0).toInt() }
|
||||
}
|
||||
|
||||
private fun getToc(epub: EpubPackage): List<EpubTocEntry> {
|
||||
// Epub 3
|
||||
epub.getNavResource()?.let { return processNav(it, Epub3Nav.TOC) }
|
||||
// Epub 2
|
||||
epub.getNcxResource()?.let { return processNcx(it, Epub2Nav.TOC) }
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
private fun getPageList(epub: EpubPackage): List<EpubTocEntry> {
|
||||
// Epub 3
|
||||
epub.getNavResource()?.let { return processNav(it, Epub3Nav.PAGELIST) }
|
||||
// Epub 2
|
||||
epub.getNcxResource()?.let { return processNcx(it, Epub2Nav.PAGELIST) }
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
private fun getLandmarks(epub: EpubPackage): List<EpubTocEntry> {
|
||||
// Epub 3
|
||||
epub.getNavResource()?.let { return processNav(it, Epub3Nav.LANDMARKS) }
|
||||
|
||||
// Epub 2
|
||||
return processOpfGuide(epub.opfDoc, epub.opfDir)
|
||||
}
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
package org.gotson.komga.infrastructure.mediacontainer.epub
|
||||
|
||||
import org.gotson.komga.domain.model.EpubTocEntry
|
||||
import org.gotson.komga.domain.model.MediaFile
|
||||
|
||||
data class EpubManifest(
|
||||
val resources: List<MediaFile>,
|
||||
val toc: List<EpubTocEntry>,
|
||||
val landmarks: List<EpubTocEntry>,
|
||||
val pageList: List<EpubTocEntry>,
|
||||
val pageCount: Int,
|
||||
)
|
@ -0,0 +1,8 @@
|
||||
package org.gotson.komga.infrastructure.mediacontainer.epub
|
||||
|
||||
data class ManifestItem(
|
||||
val id: String,
|
||||
val href: String,
|
||||
val mediaType: String,
|
||||
val properties: Set<String> = emptySet(),
|
||||
)
|
@ -0,0 +1,30 @@
|
||||
package org.gotson.komga.infrastructure.mediacontainer.epub
|
||||
|
||||
import org.gotson.komga.domain.model.EpubTocEntry
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Element
|
||||
import org.springframework.web.util.UriUtils
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.Path
|
||||
|
||||
fun EpubPackage.getNavResource(): ResourceContent? =
|
||||
manifest.values.firstOrNull { it.properties.contains("nav") }?.let { nav ->
|
||||
val href = normalizeHref(opfDir, nav.href)
|
||||
zip.getInputStream(zip.getEntry(href)).use { ResourceContent(Path(href), it.readBytes().decodeToString()) }
|
||||
}
|
||||
|
||||
fun processNav(document: ResourceContent, navElement: Epub3Nav): List<EpubTocEntry> {
|
||||
val doc = Jsoup.parse(document.content)
|
||||
val nav = doc.select("nav")
|
||||
// Jsoup selectors cannot find an attribute with namespace
|
||||
.firstOrNull { it.attributes().any { attr -> attr.key.endsWith("type") && attr.value == navElement.value } }
|
||||
return nav?.select(":root > ol > li")?.toList()?.mapNotNull { navLiElementToTocEntry(it, document.path.parent) } ?: emptyList()
|
||||
}
|
||||
|
||||
private fun navLiElementToTocEntry(element: Element, navDir: Path?): EpubTocEntry? {
|
||||
val title = element.selectFirst(":root > a, span")?.text()
|
||||
val href = element.selectFirst(":root > a")?.attr("href")?.let { UriUtils.decode(it, Charsets.UTF_8) }
|
||||
val children = element.select(":root > ol > li").mapNotNull { navLiElementToTocEntry(it, navDir) }
|
||||
if (title != null) return EpubTocEntry(title, href?.let { normalizeHref(navDir, it) }, children)
|
||||
return null
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
package org.gotson.komga.infrastructure.mediacontainer.epub
|
||||
|
||||
import org.gotson.komga.domain.model.EpubTocEntry
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Element
|
||||
import org.springframework.web.util.UriUtils
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.Path
|
||||
|
||||
private val possibleNcxItemIds = listOf("toc", "ncx", "ncxtoc")
|
||||
|
||||
fun EpubPackage.getNcxResource(): ResourceContent? =
|
||||
(manifest.values.firstOrNull { it.mediaType == "application/x-dtbncx+xml" } ?: manifest.values.firstOrNull { possibleNcxItemIds.contains(it.id) })?.let { ncx ->
|
||||
val href = normalizeHref(opfDir, ncx.href)
|
||||
zip.getInputStream(zip.getEntry(href)).use { ResourceContent(Path(href), it.readBytes().decodeToString()) }
|
||||
}
|
||||
|
||||
fun processNcx(document: ResourceContent, navType: Epub2Nav): List<EpubTocEntry> =
|
||||
Jsoup.parse(document.content)
|
||||
.select("${navType.level1} > ${navType.level2}")
|
||||
.toList()
|
||||
.mapNotNull { ncxElementToTocEntry(navType, it, document.path.parent) }
|
||||
|
||||
private fun ncxElementToTocEntry(navType: Epub2Nav, element: Element, ncxDir: Path?): EpubTocEntry? {
|
||||
val title = element.selectFirst("navLabel > text")?.text()
|
||||
val href = element.selectFirst("content")?.attr("src")?.let { UriUtils.decode(it, Charsets.UTF_8) }
|
||||
val children = element.select(":root > ${navType.level2}").toList().mapNotNull { ncxElementToTocEntry(navType, it, ncxDir) }
|
||||
if (title != null) return EpubTocEntry(title, href?.let { normalizeHref(ncxDir, it) }, children)
|
||||
return null
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
package org.gotson.komga.infrastructure.mediacontainer.epub
|
||||
|
||||
import org.gotson.komga.domain.model.EpubTocEntry
|
||||
import org.jsoup.nodes.Document
|
||||
import org.springframework.web.util.UriUtils
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import kotlin.io.path.invariantSeparatorsPathString
|
||||
|
||||
fun Document.getManifest() = select("manifest > item").associate {
|
||||
it.attr("id") to ManifestItem(
|
||||
it.attr("id"),
|
||||
it.attr("href"),
|
||||
it.attr("media-type"),
|
||||
it.attr("properties").split(" ").toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
fun normalizeHref(opfDir: Path?, href: String) = (opfDir?.resolve(href)?.normalize() ?: Paths.get(href)).invariantSeparatorsPathString
|
||||
|
||||
/**
|
||||
* Process an OPF document and extracts TOC entries
|
||||
* from the <guide> section.
|
||||
*/
|
||||
fun processOpfGuide(opf: Document, opfDir: Path?): List<EpubTocEntry> {
|
||||
val guide = opf.selectFirst("guide") ?: return emptyList()
|
||||
return guide.select("reference").map { ref ->
|
||||
EpubTocEntry(
|
||||
ref.attr("title"),
|
||||
ref.attr("href").ifBlank { null }?.let { normalizeHref(opfDir, UriUtils.decode(it, Charsets.UTF_8)) },
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
package org.gotson.komga.infrastructure.mediacontainer.epub
|
||||
|
||||
import java.nio.file.Path
|
||||
|
||||
data class ResourceContent(
|
||||
val path: Path,
|
||||
val content: String,
|
||||
)
|
@ -1,4 +1,4 @@
|
||||
package org.gotson.komga.infrastructure.mediacontainer
|
||||
package org.gotson.komga.infrastructure.mediacontainer.pdf
|
||||
|
||||
import mu.KotlinLogging
|
||||
import org.apache.pdfbox.io.MemoryUsageSetting
|
||||
@ -28,8 +28,6 @@ class PdfExtractor(
|
||||
@Qualifier("pdfResolution")
|
||||
private val resolution: Float,
|
||||
) {
|
||||
fun getPageCount(path: Path): Int = PDDocument.load(path.toFile(), MemoryUsageSetting.setupTempFileOnly()).use { pdf -> pdf.numberOfPages }
|
||||
|
||||
fun getPages(path: Path, analyzeDimensions: Boolean): List<MediaContainerEntry> =
|
||||
PDDocument.load(path.toFile(), MemoryUsageSetting.setupTempFileOnly()).use { pdf ->
|
||||
(0 until pdf.numberOfPages).map { index ->
|
@ -157,7 +157,7 @@ class ComicInfoProvider(
|
||||
|
||||
private fun getComicInfo(book: BookWithMedia): ComicInfo? {
|
||||
try {
|
||||
if (book.media.files.none { it == COMIC_INFO }) {
|
||||
if (book.media.files.none { it.fileName == COMIC_INFO }) {
|
||||
logger.debug { "Book does not contain any $COMIC_INFO file: $book" }
|
||||
return null
|
||||
}
|
||||
|
@ -11,7 +11,7 @@ import org.gotson.komga.domain.model.MediaType
|
||||
import org.gotson.komga.domain.model.MetadataPatchTarget
|
||||
import org.gotson.komga.domain.model.SeriesMetadata
|
||||
import org.gotson.komga.domain.model.SeriesMetadataPatch
|
||||
import org.gotson.komga.infrastructure.mediacontainer.EpubExtractor
|
||||
import org.gotson.komga.infrastructure.mediacontainer.epub.getPackageFile
|
||||
import org.gotson.komga.infrastructure.metadata.BookMetadataProvider
|
||||
import org.gotson.komga.infrastructure.metadata.SeriesMetadataFromBookProvider
|
||||
import org.gotson.komga.language.stripAccents
|
||||
@ -24,7 +24,6 @@ import java.time.format.DateTimeFormatter
|
||||
|
||||
@Service
|
||||
class EpubMetadataProvider(
|
||||
private val epubExtractor: EpubExtractor,
|
||||
private val isbnValidator: ISBNValidator,
|
||||
) : BookMetadataProvider, SeriesMetadataFromBookProvider {
|
||||
|
||||
@ -49,7 +48,7 @@ class EpubMetadataProvider(
|
||||
|
||||
override fun getBookMetadataFromBook(book: BookWithMedia): BookMetadataPatch? {
|
||||
if (book.media.mediaType != MediaType.EPUB.type) return null
|
||||
epubExtractor.getPackageFile(book.book.path)?.let { packageFile ->
|
||||
getPackageFile(book.book.path)?.let { packageFile ->
|
||||
val opf = Jsoup.parse(packageFile, "", Parser.xmlParser())
|
||||
|
||||
val title = opf.selectFirst("metadata > dc|title")?.text()?.ifBlank { null }
|
||||
@ -87,7 +86,7 @@ class EpubMetadataProvider(
|
||||
|
||||
override fun getSeriesMetadataFromBook(book: BookWithMedia, library: Library): SeriesMetadataPatch? {
|
||||
if (book.media.mediaType != MediaType.EPUB.type) return null
|
||||
epubExtractor.getPackageFile(book.book.path)?.let { packageFile ->
|
||||
getPackageFile(book.book.path)?.let { packageFile ->
|
||||
val opf = Jsoup.parse(packageFile, "", Parser.xmlParser())
|
||||
|
||||
val series = opf.selectFirst("metadata > *|meta[property=belongs-to-collection]")?.text()?.ifBlank { null }
|
||||
|
@ -74,6 +74,8 @@ class SecurityConfiguration(
|
||||
"/api/v1/claim",
|
||||
// used by webui
|
||||
"/api/v1/oauth2/providers",
|
||||
// epub resources - fonts are always requested anonymously, so we check for authorization within the controller method directly
|
||||
"api/v1/books/{bookId}/resource/**",
|
||||
).permitAll()
|
||||
|
||||
// all other endpoints are restricted to authenticated users
|
||||
|
@ -1,7 +1,10 @@
|
||||
package org.gotson.komga.interfaces.api
|
||||
|
||||
import org.gotson.komga.domain.model.BookPage
|
||||
import org.gotson.komga.domain.model.EpubTocEntry
|
||||
import org.gotson.komga.domain.model.Media
|
||||
import org.gotson.komga.domain.model.MediaExtensionEpub
|
||||
import org.gotson.komga.domain.model.MediaFile
|
||||
import org.gotson.komga.domain.model.MediaProfile
|
||||
import org.gotson.komga.domain.model.SeriesMetadata
|
||||
import org.gotson.komga.domain.service.BookAnalyzer
|
||||
@ -16,6 +19,7 @@ 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_EPUB
|
||||
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
|
||||
@ -36,8 +40,6 @@ 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,
|
||||
@ -128,6 +130,45 @@ class WebPubGenerator(
|
||||
}
|
||||
}
|
||||
|
||||
fun toManifestEpub(bookDto: BookDto, media: Media, seriesMetadata: SeriesMetadata): WPPublicationDto {
|
||||
val uriBuilder = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("api", "v1")
|
||||
val extension = media.extension as MediaExtensionEpub?
|
||||
return bookDto.toBasePublicationDto().let { publication ->
|
||||
publication.copy(
|
||||
mediaType = MEDIATYPE_WEBPUB_JSON,
|
||||
metadata = publication.metadata
|
||||
.withSeriesMetadata(seriesMetadata)
|
||||
.copy(conformsTo = PROFILE_EPUB),
|
||||
readingOrder = media.files.filter { it.subType == MediaFile.SubType.EPUB_PAGE }.map {
|
||||
WPLinkDto(
|
||||
href = uriBuilder.cloneBuilder().path("books/${bookDto.id}/resource/").path(it.fileName).toUriString(),
|
||||
type = it.mediaType,
|
||||
)
|
||||
},
|
||||
resources = buildThumbnailLinkDtos(bookDto.id) +
|
||||
media.files.filter { it.subType == MediaFile.SubType.EPUB_ASSET }.map {
|
||||
WPLinkDto(
|
||||
href = uriBuilder.cloneBuilder().path("books/${bookDto.id}/resource/").path(it.fileName).toUriString(),
|
||||
type = it.mediaType,
|
||||
)
|
||||
},
|
||||
toc = extension?.toc?.map { it.toWPLinkDto(uriBuilder.cloneBuilder().path("books/${bookDto.id}/resource/")) } ?: emptyList(),
|
||||
landmarks = extension?.landmarks?.map { it.toWPLinkDto(uriBuilder.cloneBuilder().path("books/${bookDto.id}/resource/")) } ?: emptyList(),
|
||||
pageList = extension?.pageList?.map { it.toWPLinkDto(uriBuilder.cloneBuilder().path("books/${bookDto.id}/resource/")) } ?: emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun EpubTocEntry.toWPLinkDto(uriBuilder: UriComponentsBuilder): WPLinkDto = WPLinkDto(
|
||||
title = title,
|
||||
href = href?.let {
|
||||
val fragment = it.substringAfterLast("#", "")
|
||||
val h = it.removeSuffix("#$fragment")
|
||||
uriBuilder.cloneBuilder().path(h).toUriString() + if (fragment.isNotEmpty()) "#$fragment" else ""
|
||||
},
|
||||
children = children.map { it.toWPLinkDto(uriBuilder) },
|
||||
)
|
||||
|
||||
private fun BookDto.toWPMetadataDto(includeOpdsLinks: Boolean = false) = WPMetadataDto(
|
||||
title = metadata.title,
|
||||
description = metadata.summary,
|
||||
@ -196,6 +237,7 @@ class WebPubGenerator(
|
||||
private fun mediaProfileToWebPub(profile: MediaProfile?): String = when (profile) {
|
||||
MediaProfile.DIVINA -> MEDIATYPE_DIVINA_JSON_VALUE
|
||||
MediaProfile.PDF -> MEDIATYPE_WEBPUB_JSON_VALUE
|
||||
MediaProfile.EPUB -> MEDIATYPE_WEBPUB_JSON_VALUE
|
||||
null -> MEDIATYPE_WEBPUB_JSON_VALUE
|
||||
}
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ const val MEDIATYPE_DIVINA_JSON_VALUE = "application/divina+json"
|
||||
const val MEDIATYPE_WEBPUB_JSON_VALUE = "application/webpub+json"
|
||||
|
||||
const val PROFILE_DIVINA = "https://readium.org/webpub-manifest/profiles/divina"
|
||||
const val PROFILE_EPUB = "https://readium.org/webpub-manifest/profiles/epub"
|
||||
const val PROFILE_PDF = "https://readium.org/webpub-manifest/profiles/pdf"
|
||||
|
||||
val MEDIATYPE_OPDS_PUBLICATION_JSON = MediaType("application", "opds-publication+json")
|
||||
|
@ -21,9 +21,10 @@ data class WPLinkDto(
|
||||
@Positive
|
||||
val height: Int? = null,
|
||||
val alternate: List<WPLinkDto> = emptyList(),
|
||||
val children: List<WPLinkDto> = emptyList(),
|
||||
)
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
data class WPPublicationDto(
|
||||
@JsonIgnore
|
||||
val mediaType: MediaType,
|
||||
@ -35,6 +36,8 @@ data class WPPublicationDto(
|
||||
val readingOrder: List<WPLinkDto> = emptyList(),
|
||||
val resources: List<WPLinkDto> = emptyList(),
|
||||
val toc: List<WPLinkDto> = emptyList(),
|
||||
val landmarks: List<WPLinkDto> = emptyList(),
|
||||
val pageList: List<WPLinkDto> = emptyList(),
|
||||
)
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||
|
@ -689,14 +689,16 @@ class OpdsController(
|
||||
val mediaTypes = when (media.profile) {
|
||||
MediaProfile.DIVINA -> media.pages.map { it.mediaType }.distinct()
|
||||
MediaProfile.PDF -> listOf(pdfImageType.mediaType)
|
||||
null -> emptyList()
|
||||
MediaProfile.EPUB, null -> emptyList()
|
||||
}
|
||||
|
||||
val opdsLinkPageStreaming = if (mediaTypes.size == 1 && mediaTypes.first() in opdsPseSupportedFormats) {
|
||||
OpdsLinkPageStreaming(mediaTypes.first(), uriBuilder("books/$id/pages/").toUriString() + "{pageNumber}", media.pageCount, readProgress?.page, readProgress?.readDate)
|
||||
} else {
|
||||
OpdsLinkPageStreaming("image/jpeg", uriBuilder("books/$id/pages/").toUriString() + "{pageNumber}?convert=jpeg", media.pageCount, readProgress?.page, readProgress?.readDate)
|
||||
}
|
||||
val opdsLinkPageStreaming =
|
||||
if (mediaTypes.isEmpty()) null
|
||||
else if (mediaTypes.size == 1 && mediaTypes.first() in opdsPseSupportedFormats) {
|
||||
OpdsLinkPageStreaming(mediaTypes.first(), uriBuilder("books/$id/pages/").toUriString() + "{pageNumber}", media.pageCount, readProgress?.page, readProgress?.readDate)
|
||||
} else {
|
||||
OpdsLinkPageStreaming("image/jpeg", uriBuilder("books/$id/pages/").toUriString() + "{pageNumber}?convert=jpeg", media.pageCount, readProgress?.page, readProgress?.readDate)
|
||||
}
|
||||
|
||||
return OpdsEntryAcquisition(
|
||||
title = "${prepend(this)}${metadata.title}",
|
||||
@ -707,7 +709,7 @@ class OpdsController(
|
||||
if (metadata.summary.isNotBlank()) append("\n\n${metadata.summary}")
|
||||
},
|
||||
authors = metadata.authors.map { OpdsAuthor(it.name) },
|
||||
links = listOf(
|
||||
links = listOfNotNull(
|
||||
OpdsLinkImageThumbnail("image/jpeg", uriBuilder("books/$id/thumbnail/small").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()),
|
||||
|
@ -5,8 +5,10 @@ import io.swagger.v3.oas.annotations.Parameter
|
||||
import io.swagger.v3.oas.annotations.media.Content
|
||||
import io.swagger.v3.oas.annotations.media.Schema
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.validation.Valid
|
||||
import mu.KotlinLogging
|
||||
import org.apache.commons.io.FilenameUtils
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.gotson.komga.application.tasks.HIGHEST_PRIORITY
|
||||
import org.gotson.komga.application.tasks.HIGH_PRIORITY
|
||||
@ -38,7 +40,6 @@ import org.gotson.komga.domain.persistence.ThumbnailBookRepository
|
||||
import org.gotson.komga.domain.service.BookAnalyzer
|
||||
import org.gotson.komga.domain.service.BookLifecycle
|
||||
import org.gotson.komga.infrastructure.image.ImageAnalyzer
|
||||
import org.gotson.komga.infrastructure.image.ImageConverter
|
||||
import org.gotson.komga.infrastructure.image.ImageType
|
||||
import org.gotson.komga.infrastructure.jooq.UnpagedSorted
|
||||
import org.gotson.komga.infrastructure.mediacontainer.ContentDetector
|
||||
@ -77,6 +78,7 @@ import org.springframework.http.MediaType
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.util.AntPathMatcher
|
||||
import org.springframework.util.MimeTypeUtils
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
@ -94,7 +96,9 @@ import org.springframework.web.context.request.ServletWebRequest
|
||||
import org.springframework.web.context.request.WebRequest
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
import org.springframework.web.servlet.HandlerMapping
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
|
||||
import org.springframework.web.util.UriUtils
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.OutputStream
|
||||
import java.nio.charset.StandardCharsets.UTF_8
|
||||
@ -105,6 +109,7 @@ import kotlin.io.path.name
|
||||
import org.gotson.komga.domain.model.MediaType as KomgaMediaType
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
private val FONT_EXTENSIONS = listOf("otf", "woff", "woff2", "eot", "ttf", "svg")
|
||||
|
||||
@RestController
|
||||
@RequestMapping(produces = [MediaType.APPLICATION_JSON_VALUE])
|
||||
@ -122,7 +127,6 @@ class BookController(
|
||||
private val imageAnalyzer: ImageAnalyzer,
|
||||
private val eventPublisher: ApplicationEventPublisher,
|
||||
private val thumbnailBookRepository: ThumbnailBookRepository,
|
||||
private val imageConverter: ImageConverter,
|
||||
private val webPubGenerator: WebPubGenerator,
|
||||
) {
|
||||
|
||||
@ -657,10 +661,75 @@ class BookController(
|
||||
when (KomgaMediaType.fromMediaType(media.mediaType)?.profile) {
|
||||
MediaProfile.DIVINA -> getWebPubManifestDivina(principal, bookId)
|
||||
MediaProfile.PDF -> getWebPubManifestPdf(principal, bookId)
|
||||
MediaProfile.EPUB -> getWebPubManifestEpub(principal, bookId)
|
||||
null -> throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed")
|
||||
}
|
||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
|
||||
@GetMapping(
|
||||
value = ["api/v1/books/{bookId}/resource/**"],
|
||||
produces = ["*/*"],
|
||||
)
|
||||
fun getBookResource(
|
||||
request: HttpServletRequest,
|
||||
@AuthenticationPrincipal principal: KomgaPrincipal?,
|
||||
@PathVariable bookId: String,
|
||||
): ResponseEntity<ByteArray> {
|
||||
val resourceName = AntPathMatcher().extractPathWithinPattern(request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE) as String, request.requestURI).let { UriUtils.decode(it, UTF_8) }
|
||||
val isFont = FONT_EXTENSIONS.contains(FilenameUtils.getExtension(resourceName).lowercase())
|
||||
|
||||
if (!isFont && principal == null) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
||||
|
||||
val book = bookRepository.findByIdOrNull(bookId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
val media = mediaRepository.findById(book.id)
|
||||
|
||||
if (ServletWebRequest(request).checkNotModified(getBookLastModified(media))) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.NOT_MODIFIED)
|
||||
.setNotModified(media)
|
||||
.body(ByteArray(0))
|
||||
}
|
||||
|
||||
if (media.profile != MediaProfile.EPUB) throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Book media type '${media.mediaType}' not compatible with requested profile")
|
||||
if (!isFont) principal!!.user.checkContentRestriction(book)
|
||||
|
||||
val res = media.files.firstOrNull { it.fileName == resourceName } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
val bytes = bookAnalyzer.getFileContent(BookWithMedia(book, media), resourceName)
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.headers(
|
||||
HttpHeaders().apply {
|
||||
contentDisposition = ContentDisposition.builder("inline")
|
||||
.filename(FilenameUtils.getName(resourceName), UTF_8)
|
||||
.build()
|
||||
},
|
||||
)
|
||||
.contentType(getMediaTypeOrDefault(res.mediaType))
|
||||
.setNotModified(media)
|
||||
.body(bytes)
|
||||
}
|
||||
|
||||
@GetMapping(
|
||||
value = ["api/v1/books/{bookId}/manifest/epub"],
|
||||
produces = [MEDIATYPE_WEBPUB_JSON_VALUE],
|
||||
)
|
||||
fun getWebPubManifestEpub(
|
||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||
@PathVariable bookId: String,
|
||||
): ResponseEntity<WPPublicationDto> =
|
||||
bookDtoRepository.findByIdOrNull(bookId, principal.user.id)?.let { bookDto ->
|
||||
if (bookDto.media.mediaProfile != MediaProfile.EPUB.name) throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Book media type '${bookDto.media.mediaType}' not compatible with requested profile")
|
||||
principal.user.checkContentRestriction(bookDto)
|
||||
val manifest = webPubGenerator.toManifestEpub(
|
||||
bookDto,
|
||||
mediaRepository.findById(bookId),
|
||||
seriesMetadataRepository.findById(bookDto.seriesId),
|
||||
)
|
||||
ResponseEntity.ok()
|
||||
.contentType(manifest.mediaType)
|
||||
.body(manifest)
|
||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
|
||||
@GetMapping(
|
||||
value = ["api/v1/books/{bookId}/manifest/pdf"],
|
||||
produces = [MEDIATYPE_WEBPUB_JSON_VALUE],
|
||||
|
@ -102,7 +102,7 @@ class TransientBooksController(
|
||||
sizeBytes = bookPage.fileSize,
|
||||
)
|
||||
},
|
||||
files = media.files,
|
||||
files = media.files.map { it.fileName },
|
||||
comment = media.comment ?: "",
|
||||
)
|
||||
}
|
||||
|
@ -130,7 +130,7 @@ class BookAnalyzerTest(
|
||||
|
||||
assertThat(media.mediaType).isEqualTo("application/epub+zip")
|
||||
assertThat(media.status).isEqualTo(Media.Status.READY)
|
||||
assertThat(media.pages).hasSize(2)
|
||||
assertThat(media.pages).hasSize(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -4,7 +4,10 @@ import org.assertj.core.api.Assertions.assertThat
|
||||
import org.assertj.core.api.Assertions.catchThrowable
|
||||
import org.gotson.komga.domain.model.BookPage
|
||||
import org.gotson.komga.domain.model.Dimension
|
||||
import org.gotson.komga.domain.model.EpubTocEntry
|
||||
import org.gotson.komga.domain.model.Media
|
||||
import org.gotson.komga.domain.model.MediaExtensionEpub
|
||||
import org.gotson.komga.domain.model.MediaFile
|
||||
import org.gotson.komga.domain.model.MediaType
|
||||
import org.gotson.komga.domain.model.makeBook
|
||||
import org.gotson.komga.domain.model.makeLibrary
|
||||
@ -73,7 +76,11 @@ class MediaDaoTest(
|
||||
fileSize = 10,
|
||||
),
|
||||
),
|
||||
files = listOf("ComicInfo.xml"),
|
||||
files = listOf(MediaFile("ComicInfo.xml", "application/xml", MediaFile.SubType.EPUB_ASSET, 3)),
|
||||
extension = MediaExtensionEpub(
|
||||
toc = listOf(EpubTocEntry("title", "href", listOf(EpubTocEntry("subtitle", "subhref")))),
|
||||
landmarks = listOf(EpubTocEntry("title2", "href2", listOf(EpubTocEntry("subtitle2", "subhref2")))),
|
||||
),
|
||||
comment = "comment",
|
||||
bookId = book.id,
|
||||
)
|
||||
@ -96,7 +103,15 @@ class MediaDaoTest(
|
||||
assertThat(fileSize).isEqualTo(media.pages.first().fileSize)
|
||||
}
|
||||
assertThat(created.files).hasSize(1)
|
||||
assertThat(created.files.first()).isEqualTo(media.files.first())
|
||||
with(created.files.first()) {
|
||||
assertThat(fileName).isEqualTo(media.files.first().fileName)
|
||||
assertThat(mediaType).isEqualTo(media.files.first().mediaType)
|
||||
assertThat(subType).isEqualTo(media.files.first().subType)
|
||||
assertThat(fileSize).isEqualTo(media.files.first().fileSize)
|
||||
}
|
||||
assertThat(created.extension).isNotNull
|
||||
assertThat(created.extension).isInstanceOf(MediaExtensionEpub::class.java)
|
||||
assertThat(created.extension).isEqualTo(media.extension)
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -125,7 +140,10 @@ class MediaDaoTest(
|
||||
mediaType = "image/jpeg",
|
||||
),
|
||||
),
|
||||
files = listOf("ComicInfo.xml"),
|
||||
files = listOf(MediaFile("ComicInfo.xml", "application/xml", MediaFile.SubType.EPUB_ASSET, 5)),
|
||||
extension = MediaExtensionEpub(
|
||||
landmarks = listOf(EpubTocEntry("title2", "href2", listOf(EpubTocEntry("subtitle2", "subhref2")))),
|
||||
),
|
||||
comment = "comment",
|
||||
bookId = book.id,
|
||||
)
|
||||
@ -146,7 +164,10 @@ class MediaDaoTest(
|
||||
fileSize = 10,
|
||||
),
|
||||
),
|
||||
files = listOf("id.txt"),
|
||||
files = listOf(MediaFile("id.txt")),
|
||||
extension = MediaExtensionEpub(
|
||||
toc = listOf(EpubTocEntry("title", "href", listOf(EpubTocEntry("subtitle", "subhref")))),
|
||||
),
|
||||
comment = "comment2",
|
||||
)
|
||||
}
|
||||
@ -167,7 +188,11 @@ class MediaDaoTest(
|
||||
assertThat(modified.pages.first().dimension).isEqualTo(updated.pages.first().dimension)
|
||||
assertThat(modified.pages.first().fileHash).isEqualTo(updated.pages.first().fileHash)
|
||||
assertThat(modified.pages.first().fileSize).isEqualTo(updated.pages.first().fileSize)
|
||||
assertThat(modified.files.first()).isEqualTo(updated.files.first())
|
||||
assertThat(modified.files.first().fileName).isEqualTo(updated.files.first().fileName)
|
||||
assertThat(modified.files.first().mediaType).isEqualTo(updated.files.first().mediaType)
|
||||
assertThat(modified.files.first().subType).isEqualTo(updated.files.first().subType)
|
||||
assertThat(modified.files.first().fileSize).isEqualTo(updated.files.first().fileSize)
|
||||
assertThat(modified.extension).isEqualTo(updated.extension)
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -181,7 +206,7 @@ class MediaDaoTest(
|
||||
mediaType = "image/jpeg",
|
||||
),
|
||||
),
|
||||
files = listOf("ComicInfo.xml"),
|
||||
files = listOf(MediaFile("ComicInfo.xml")),
|
||||
comment = "comment",
|
||||
bookId = book.id,
|
||||
)
|
||||
|
@ -113,7 +113,6 @@ class PageHashDaoTest(
|
||||
fileSize = it.toLong(),
|
||||
)
|
||||
},
|
||||
files = listOf("ComicInfo.xml"),
|
||||
comment = "comment",
|
||||
bookId = books.first().id,
|
||||
)
|
||||
|
@ -282,7 +282,7 @@ class SeriesDtoDaoTest(
|
||||
seriesLifecycle.createSeries(makeSeries("Batman", library.id))
|
||||
|
||||
searchIndexLifecycle.rebuildIndex()
|
||||
Thread.sleep(100) // index rebuild is done asynchronously, and need a slight delay to be updated
|
||||
Thread.sleep(500) // index rebuild is done asynchronously, and need a slight delay to be updated
|
||||
|
||||
// when
|
||||
val found = seriesDtoDao.findAll(
|
||||
@ -307,7 +307,7 @@ class SeriesDtoDaoTest(
|
||||
}
|
||||
|
||||
searchIndexLifecycle.rebuildIndex()
|
||||
Thread.sleep(100) // index rebuild is done asynchronously, and need a slight delay to be updated
|
||||
Thread.sleep(500) // index rebuild is done asynchronously, and need a slight delay to be updated
|
||||
|
||||
// when
|
||||
val found = seriesDtoDao.findAll(
|
||||
@ -332,7 +332,7 @@ class SeriesDtoDaoTest(
|
||||
}
|
||||
|
||||
searchIndexLifecycle.rebuildIndex()
|
||||
Thread.sleep(100) // index rebuild is done asynchronously, and need a slight delay to be updated
|
||||
Thread.sleep(500) // index rebuild is done asynchronously, and need a slight delay to be updated
|
||||
|
||||
// when
|
||||
val found = seriesDtoDao.findAll(
|
||||
@ -357,7 +357,7 @@ class SeriesDtoDaoTest(
|
||||
}
|
||||
|
||||
searchIndexLifecycle.rebuildIndex()
|
||||
Thread.sleep(100) // index rebuild is done asynchronously, and need a slight delay to be updated
|
||||
Thread.sleep(500) // index rebuild is done asynchronously, and need a slight delay to be updated
|
||||
|
||||
// when
|
||||
val found = seriesDtoDao.findAll(
|
||||
@ -382,7 +382,7 @@ class SeriesDtoDaoTest(
|
||||
}
|
||||
|
||||
searchIndexLifecycle.rebuildIndex()
|
||||
Thread.sleep(100) // index rebuild is done asynchronously, and need a slight delay to be updated
|
||||
Thread.sleep(500) // index rebuild is done asynchronously, and need a slight delay to be updated
|
||||
|
||||
// when
|
||||
val found = seriesDtoDao.findAll(
|
||||
@ -407,7 +407,7 @@ class SeriesDtoDaoTest(
|
||||
}
|
||||
|
||||
searchIndexLifecycle.rebuildIndex()
|
||||
Thread.sleep(100) // index rebuild is done asynchronously, and need a slight delay to be updated
|
||||
Thread.sleep(500) // index rebuild is done asynchronously, and need a slight delay to be updated
|
||||
|
||||
// when
|
||||
val found = seriesDtoDao.findAll(
|
||||
@ -438,7 +438,7 @@ class SeriesDtoDaoTest(
|
||||
|
||||
seriesMetadataLifecycle.aggregateMetadata(series)
|
||||
searchIndexLifecycle.rebuildIndex()
|
||||
Thread.sleep(100) // index rebuild is done asynchronously, and need a slight delay to be updated
|
||||
Thread.sleep(500) // index rebuild is done asynchronously, and need a slight delay to be updated
|
||||
|
||||
// when
|
||||
val foundByBookTag = seriesDtoDao.findAll(
|
||||
@ -506,7 +506,7 @@ class SeriesDtoDaoTest(
|
||||
}
|
||||
|
||||
searchIndexLifecycle.rebuildIndex()
|
||||
Thread.sleep(100) // index rebuild is done asynchronously, and need a slight delay to be updated
|
||||
Thread.sleep(500) // index rebuild is done asynchronously, and need a slight delay to be updated
|
||||
|
||||
// when
|
||||
val found = seriesDtoDao.findAll(
|
||||
@ -531,7 +531,7 @@ class SeriesDtoDaoTest(
|
||||
}
|
||||
|
||||
searchIndexLifecycle.rebuildIndex()
|
||||
Thread.sleep(100) // index rebuild is done asynchronously, and need a slight delay to be updated
|
||||
Thread.sleep(500) // index rebuild is done asynchronously, and need a slight delay to be updated
|
||||
|
||||
// when
|
||||
val found = seriesDtoDao.findAll(
|
||||
@ -564,7 +564,7 @@ class SeriesDtoDaoTest(
|
||||
}
|
||||
|
||||
searchIndexLifecycle.rebuildIndex()
|
||||
Thread.sleep(100) // index rebuild is done asynchronously, and need a slight delay to be updated
|
||||
Thread.sleep(500) // index rebuild is done asynchronously, and need a slight delay to be updated
|
||||
|
||||
// when
|
||||
val found = seriesDtoDao.findAll(
|
||||
@ -598,7 +598,7 @@ class SeriesDtoDaoTest(
|
||||
|
||||
seriesMetadataLifecycle.aggregateMetadata(series)
|
||||
searchIndexLifecycle.rebuildIndex()
|
||||
Thread.sleep(100) // index rebuild is done asynchronously, and need a slight delay to be updated
|
||||
Thread.sleep(500) // index rebuild is done asynchronously, and need a slight delay to be updated
|
||||
|
||||
// when
|
||||
val foundGeneric = seriesDtoDao.findAll(
|
||||
@ -643,7 +643,7 @@ class SeriesDtoDaoTest(
|
||||
|
||||
seriesMetadataLifecycle.aggregateMetadata(series)
|
||||
searchIndexLifecycle.rebuildIndex()
|
||||
Thread.sleep(100) // index rebuild is done asynchronously, and need a slight delay to be updated // index rebuild is done asynchronously, and need a slight delay to be updated
|
||||
Thread.sleep(500) // index rebuild is done asynchronously, and need a slight delay to be updated // index rebuild is done asynchronously, and need a slight delay to be updated
|
||||
|
||||
// when
|
||||
val found = seriesDtoDao.findAll(
|
||||
@ -664,7 +664,7 @@ class SeriesDtoDaoTest(
|
||||
seriesLifecycle.createSeries(makeSeries("Batman and Robin", library.id))
|
||||
|
||||
searchIndexLifecycle.rebuildIndex()
|
||||
Thread.sleep(100) // index rebuild is done asynchronously, and need a slight delay to be updated
|
||||
Thread.sleep(500) // index rebuild is done asynchronously, and need a slight delay to be updated
|
||||
|
||||
// when
|
||||
val found = seriesDtoDao.findAll(
|
||||
|
@ -1,46 +0,0 @@
|
||||
package org.gotson.komga.infrastructure.mediacontainer
|
||||
|
||||
import org.apache.tika.config.TikaConfig
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.gotson.komga.domain.model.Dimension
|
||||
import org.gotson.komga.infrastructure.image.ImageAnalyzer
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.core.io.ClassPathResource
|
||||
|
||||
class EpubExtractorTest {
|
||||
|
||||
private val contentDetector = ContentDetector(TikaConfig())
|
||||
private val imageAnalyzer = ImageAnalyzer()
|
||||
private val zipExtractor = ZipExtractor(contentDetector, imageAnalyzer)
|
||||
|
||||
private val epubExtractor = EpubExtractor(zipExtractor, contentDetector, imageAnalyzer)
|
||||
|
||||
@Test
|
||||
fun `given epub 3 file when parsing for entries then returns all images contained in pages`() {
|
||||
val epubResource = ClassPathResource("epub/The Incomplete Theft - Ralph Burke.epub")
|
||||
|
||||
val entries = epubExtractor.getEntries(epubResource.file.toPath(), true)
|
||||
|
||||
assertThat(entries).hasSize(1)
|
||||
with(entries.first()) {
|
||||
assertThat(name).isEqualTo("cover.jpeg")
|
||||
assertThat(mediaType).isEqualTo("image/jpeg")
|
||||
assertThat(dimension).isEqualTo(Dimension(461, 616))
|
||||
assertThat(fileSize).isEqualTo(56756)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given epub 3 file when parsing for entries without analyzing dimensions then returns all images contained in pages without dimensions`() {
|
||||
val epubResource = ClassPathResource("epub/The Incomplete Theft - Ralph Burke.epub")
|
||||
|
||||
val entries = epubExtractor.getEntries(epubResource.file.toPath(), false)
|
||||
|
||||
assertThat(entries).hasSize(1)
|
||||
with(entries.first()) {
|
||||
assertThat(name).isEqualTo("cover.jpeg")
|
||||
assertThat(mediaType).isEqualTo("image/jpeg")
|
||||
assertThat(dimension).isNull()
|
||||
}
|
||||
}
|
||||
}
|
@ -1,9 +1,10 @@
|
||||
package org.gotson.komga.infrastructure.mediacontainer
|
||||
package org.gotson.komga.infrastructure.mediacontainer.divina
|
||||
|
||||
import org.apache.tika.config.TikaConfig
|
||||
import org.assertj.core.api.Assertions
|
||||
import org.gotson.komga.domain.model.Dimension
|
||||
import org.gotson.komga.infrastructure.image.ImageAnalyzer
|
||||
import org.gotson.komga.infrastructure.mediacontainer.ContentDetector
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.core.io.ClassPathResource
|
||||
|
@ -1,9 +1,10 @@
|
||||
package org.gotson.komga.infrastructure.mediacontainer
|
||||
package org.gotson.komga.infrastructure.mediacontainer.divina
|
||||
|
||||
import org.apache.tika.config.TikaConfig
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.gotson.komga.domain.model.Dimension
|
||||
import org.gotson.komga.infrastructure.image.ImageAnalyzer
|
||||
import org.gotson.komga.infrastructure.mediacontainer.ContentDetector
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.core.io.ClassPathResource
|
||||
|
@ -0,0 +1,73 @@
|
||||
package org.gotson.komga.infrastructure.mediacontainer.epub
|
||||
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.gotson.komga.domain.model.EpubTocEntry
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.Arguments
|
||||
import org.junit.jupiter.params.provider.MethodSource
|
||||
import org.springframework.core.io.ClassPathResource
|
||||
import java.util.stream.Stream
|
||||
|
||||
class NavTest {
|
||||
@ParameterizedTest
|
||||
@MethodSource("paramSource")
|
||||
fun `given nav document when parsing nav section then nav entries are valid`(navType: Epub3Nav, prefix: String?, expectedProvider: (String) -> List<EpubTocEntry>) {
|
||||
// given
|
||||
val navResource = ClassPathResource("epub/nav.xhtml")
|
||||
val navString = navResource.inputStream.readAllBytes().decodeToString()
|
||||
|
||||
// when
|
||||
val nav = processNav(navString, navType)
|
||||
|
||||
// then
|
||||
val expectedNav = expectedProvider(prefix?.let { "$it/" } ?: "")
|
||||
|
||||
assertThat(nav).isEqualTo(expectedNav)
|
||||
}
|
||||
|
||||
private fun paramSource(): Stream<Arguments> {
|
||||
return Stream.of(
|
||||
Arguments.of(Epub3Nav.TOC, null, ::getExpectedNavToc),
|
||||
Arguments.of(Epub3Nav.TOC, "PREFIX", ::getExpectedNavToc),
|
||||
Arguments.of(Epub3Nav.LANDMARKS, null, ::getExpectedNavLandmarks),
|
||||
Arguments.of(Epub3Nav.LANDMARKS, "PREFIX", ::getExpectedNavLandmarks),
|
||||
Arguments.of(Epub3Nav.PAGELIST, null, ::getExpectedNavPageList),
|
||||
Arguments.of(Epub3Nav.PAGELIST, "PREFIX", ::getExpectedNavPageList),
|
||||
)
|
||||
}
|
||||
|
||||
private fun getExpectedNavToc(prefix: String = "") = listOf(
|
||||
EpubTocEntry("Cover", "${prefix}cover.xhtml"),
|
||||
EpubTocEntry("Title Page", "${prefix}titlepage.xhtml"),
|
||||
EpubTocEntry("Copyright", "${prefix}copyright.xhtml"),
|
||||
EpubTocEntry("Table of Contents", "${prefix}toc.xhtml"),
|
||||
EpubTocEntry("An unlinked heading", null),
|
||||
EpubTocEntry(
|
||||
"Introduction",
|
||||
"${prefix}introduction.xhtml",
|
||||
children = listOf(
|
||||
EpubTocEntry("Spring", "${prefix}chapter 001.xhtml"),
|
||||
EpubTocEntry("Summer", "${prefix}chapter 027.xhtml"),
|
||||
EpubTocEntry("Fall", "${prefix}chapter053.xhtml"),
|
||||
EpubTocEntry("Winter", "${prefix}chapter079.xhtml"),
|
||||
),
|
||||
),
|
||||
EpubTocEntry("Acknowledgments", "${prefix}acknowledgements.xhtml"),
|
||||
)
|
||||
|
||||
private fun getExpectedNavLandmarks(prefix: String = "") = listOf(
|
||||
EpubTocEntry("Begin Reading", "${prefix}cover.xhtml#coverimage"),
|
||||
EpubTocEntry("Table of Contents", "${prefix}toc.xhtml"),
|
||||
)
|
||||
|
||||
private fun getExpectedNavPageList(prefix: String = "") = listOf(
|
||||
EpubTocEntry("Cover Page", "${prefix}xhtml/cover.xhtml"),
|
||||
EpubTocEntry("iii", "${prefix}xhtml/title.xhtml#pg_iii"),
|
||||
EpubTocEntry("1", "${prefix}xhtml/chapter1.xhtml#pg_1"),
|
||||
EpubTocEntry("2", "${prefix}xhtml/chapter1.xhtml#pg_2"),
|
||||
EpubTocEntry("107", "${prefix}xhtml/acknowledgments.xhtml#pg_107"),
|
||||
EpubTocEntry("ii", "${prefix}xhtml/adcard.xhtml#pg_ii"),
|
||||
EpubTocEntry("109", "${prefix}xhtml/abouttheauthor.xhtml#pg_109"),
|
||||
EpubTocEntry("iv", "${prefix}xhtml/copyright.xhtml#pg_iv"),
|
||||
)
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
package org.gotson.komga.infrastructure.mediacontainer.epub
|
||||
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.gotson.komga.domain.model.EpubTocEntry
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.Arguments
|
||||
import org.junit.jupiter.params.provider.MethodSource
|
||||
import org.springframework.core.io.ClassPathResource
|
||||
import java.util.stream.Stream
|
||||
|
||||
class NcxTest {
|
||||
@ParameterizedTest
|
||||
@MethodSource("paramSource")
|
||||
fun `given ncx document when parsing nav then nav entries are valid`(navType: Epub2Nav, prefix: String?, expectedProvider: (String) -> List<EpubTocEntry>) {
|
||||
// given
|
||||
val ncxResource = ClassPathResource("epub/toc.ncx")
|
||||
val ncxString = ncxResource.inputStream.readAllBytes().decodeToString()
|
||||
|
||||
// when
|
||||
val ncxNav = processNcx(ncxString, navType)
|
||||
|
||||
// then
|
||||
val expectedNav = expectedProvider(prefix?.let { "$it/" } ?: "")
|
||||
|
||||
assertThat(ncxNav).isEqualTo(expectedNav)
|
||||
}
|
||||
|
||||
private fun paramSource(): Stream<Arguments> {
|
||||
return Stream.of(
|
||||
Arguments.of(Epub2Nav.TOC, null, ::getExpectedNcxToc),
|
||||
Arguments.of(Epub2Nav.TOC, "PREFIX", ::getExpectedNcxToc),
|
||||
Arguments.of(Epub2Nav.PAGELIST, null, ::getExpectedNcxPageList),
|
||||
Arguments.of(Epub2Nav.PAGELIST, "PREFIX", ::getExpectedNcxPageList),
|
||||
)
|
||||
}
|
||||
|
||||
private fun getExpectedNcxToc(prefix: String = "") = listOf(
|
||||
EpubTocEntry("COVER", "${prefix}Text/Mart_9780553897852_epub_cvi_r1.htm#b02-cvi"),
|
||||
EpubTocEntry("BRAN", "${prefix}Text/Mart_9780553897852_epub_c69_r1.htm"),
|
||||
EpubTocEntry(
|
||||
"APPENDIX",
|
||||
"${prefix}Text/Mart_9780553897852_epub_app_r1.htm",
|
||||
children = listOf(
|
||||
EpubTocEntry("THE KINGS AND THEIR COURTS", "${prefix}Text/Mart_9780553897852_epub_app_r1.htm#apps01.00"),
|
||||
EpubTocEntry("THE KING ON THE IRON THRONE", "${prefix}Text/Mart_9780553897852_epub_app_r1.htm#apps01.01"),
|
||||
EpubTocEntry("THE KING IN THE NARROW SEA", "${prefix}Text/Mart_9780553897852_epub_app_r1.htm#apps01.02"),
|
||||
EpubTocEntry("THE KING IN HIGHGARDEN", "${prefix}Text/Mart_9780553897852_epub_app_r1.htm#apps01.03"),
|
||||
EpubTocEntry("THE KING IN THE NORTH", "${prefix}Text/Mart_9780553897852_epub_app_r1.htm#apps01.04"),
|
||||
EpubTocEntry(
|
||||
"THE QUEEN ACROSS THE WATER",
|
||||
"${prefix}Text/Mart_9780553897852_epub_app_r1.htm#apps01.05",
|
||||
children = listOf(
|
||||
EpubTocEntry("Another level", "${prefix}Text/Mart_9780553897852 epub_app_r1.htm#apps01.06"),
|
||||
EpubTocEntry("Yet another level", "${prefix}Text/Mart_9780553897852 epub_app_r1.htm#apps01.07"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
EpubTocEntry("ACKNOWLEDGMENTS", "${prefix}Text/Mart_9780553897852_epub_ack_r1.htm"),
|
||||
)
|
||||
|
||||
private fun getExpectedNcxPageList(prefix: String = "") = listOf(
|
||||
EpubTocEntry("Cover Page", "${prefix}xhtml/cover.xhtml"),
|
||||
EpubTocEntry("iii", "${prefix}xhtml/title.xhtml#pg_iii"),
|
||||
EpubTocEntry("v", "${prefix}xhtml/dedication.xhtml#pg_v"),
|
||||
EpubTocEntry("vii", "${prefix}xhtml/formoreinformation.xhtml#pg_vii"),
|
||||
EpubTocEntry("viii", "${prefix}xhtml/formoreinformation.xhtml#pg_viii"),
|
||||
EpubTocEntry("ix", "${prefix}xhtml/formoreinformation.xhtml#pg_ix"),
|
||||
EpubTocEntry("x", "${prefix}xhtml/formoreinformation.xhtml#pg_x"),
|
||||
EpubTocEntry("xi", "${prefix}xhtml/formoreinformation.xhtml#pg_xi"),
|
||||
EpubTocEntry("1", "${prefix}xhtml/chapter1.xhtml#pg_1"),
|
||||
EpubTocEntry("2", "${prefix}xhtml/chapter1.xhtml#pg_2"),
|
||||
EpubTocEntry("3", "${prefix}xhtml/chapter1.xhtml#pg_3"),
|
||||
)
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
package org.gotson.komga.infrastructure.mediacontainer.epub
|
||||
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.gotson.komga.domain.model.EpubTocEntry
|
||||
import org.jsoup.Jsoup
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.ValueSource
|
||||
import org.springframework.core.io.ClassPathResource
|
||||
import java.nio.file.Path
|
||||
|
||||
class OpfTest {
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = ["", "PREFIX"])
|
||||
fun `given ncx document and opfDir when landmarks TOC then TOC entries are valid`(prefix: String) {
|
||||
// given
|
||||
val opfResource = ClassPathResource("epub/clash.opf")
|
||||
val opfString = opfResource.inputStream.readAllBytes().decodeToString()
|
||||
val opfDoc = Jsoup.parse(opfString)
|
||||
|
||||
// when
|
||||
val opfLandmarks = processOpfGuide(opfDoc, if (prefix.isBlank()) null else Path.of(prefix))
|
||||
|
||||
// then
|
||||
val expectedToc = getExpectedOpfLandmarks(if (prefix.isBlank()) "" else "$prefix/")
|
||||
|
||||
assertThat(opfLandmarks).isEqualTo(expectedToc)
|
||||
}
|
||||
|
||||
private fun getExpectedOpfLandmarks(prefix: String = "") = listOf(
|
||||
EpubTocEntry("Table Of Contents", "${prefix}Text/Mart_9780553897852_epub_toc_r1.htm"),
|
||||
EpubTocEntry("Text", "${prefix}Text/Mart_9780553897852_epub_prl_r1.htm"),
|
||||
EpubTocEntry("Cover", "${prefix}Text/Mart_9780553897852_epub_cvi_r1.htm"),
|
||||
)
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package org.gotson.komga.infrastructure.mediacontainer
|
||||
package org.gotson.komga.infrastructure.mediacontainer.pdf
|
||||
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.gotson.komga.infrastructure.image.ImageType
|
@ -8,6 +8,7 @@ import org.assertj.core.api.Assertions.assertThat
|
||||
import org.gotson.komga.domain.model.BookMetadataPatch
|
||||
import org.gotson.komga.domain.model.BookWithMedia
|
||||
import org.gotson.komga.domain.model.Media
|
||||
import org.gotson.komga.domain.model.MediaFile
|
||||
import org.gotson.komga.domain.model.SeriesMetadata
|
||||
import org.gotson.komga.domain.model.WebLink
|
||||
import org.gotson.komga.domain.model.makeBook
|
||||
@ -39,7 +40,7 @@ class ComicInfoProviderTest {
|
||||
private val media = Media(
|
||||
status = Media.Status.READY,
|
||||
mediaType = "application/zip",
|
||||
files = listOf("ComicInfo.xml"),
|
||||
files = listOf(MediaFile("ComicInfo.xml")),
|
||||
)
|
||||
|
||||
@Nested
|
||||
|
@ -1,9 +1,9 @@
|
||||
package org.gotson.komga.infrastructure.metadata.epub
|
||||
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import org.apache.commons.validator.routines.ISBNValidator
|
||||
import org.apache.tika.config.TikaConfig
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.gotson.komga.domain.model.Author
|
||||
import org.gotson.komga.domain.model.BookWithMedia
|
||||
@ -11,10 +11,8 @@ import org.gotson.komga.domain.model.Media
|
||||
import org.gotson.komga.domain.model.SeriesMetadata
|
||||
import org.gotson.komga.domain.model.makeBook
|
||||
import org.gotson.komga.domain.model.makeLibrary
|
||||
import org.gotson.komga.infrastructure.image.ImageAnalyzer
|
||||
import org.gotson.komga.infrastructure.mediacontainer.ContentDetector
|
||||
import org.gotson.komga.infrastructure.mediacontainer.EpubExtractor
|
||||
import org.gotson.komga.infrastructure.mediacontainer.ZipExtractor
|
||||
import org.gotson.komga.infrastructure.mediacontainer.epub.getPackageFile
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.core.io.ClassPathResource
|
||||
@ -22,13 +20,10 @@ import java.time.LocalDate
|
||||
|
||||
class EpubMetadataProviderTest {
|
||||
|
||||
private val mockExtractor = mockk<EpubExtractor>()
|
||||
private val isbnValidator = ISBNValidator(true)
|
||||
private val epubMetadataProvider = EpubMetadataProvider(mockExtractor, isbnValidator)
|
||||
private val epubMetadataProvider = EpubMetadataProvider(isbnValidator)
|
||||
|
||||
private val contentDetector = ContentDetector(TikaConfig())
|
||||
private val imageAnalyzer = ImageAnalyzer()
|
||||
private val epubMetadataProviderProper = EpubMetadataProvider(EpubExtractor(ZipExtractor(contentDetector, imageAnalyzer), contentDetector, imageAnalyzer), ISBNValidator(true))
|
||||
private val epubMetadataProviderProper = EpubMetadataProvider(ISBNValidator(true))
|
||||
|
||||
private val book = makeBook("book")
|
||||
private val media = Media(
|
||||
@ -36,13 +31,19 @@ class EpubMetadataProviderTest {
|
||||
mediaType = "application/epub+zip",
|
||||
)
|
||||
|
||||
@AfterEach
|
||||
fun cleanup() {
|
||||
unmockkStatic(::getPackageFile)
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class Book {
|
||||
|
||||
@Test
|
||||
fun `given epub 3 opf when getting book metadata then metadata patch is valid`() {
|
||||
val opf = ClassPathResource("epub/Panik im Paradies.opf")
|
||||
every { mockExtractor.getPackageFile(any()) } returns opf.file.readText()
|
||||
mockkStatic(::getPackageFile)
|
||||
every { getPackageFile(any()) } returns opf.file.readText()
|
||||
|
||||
val patch = epubMetadataProvider.getBookMetadataFromBook(BookWithMedia(book, media))
|
||||
|
||||
@ -61,7 +62,8 @@ class EpubMetadataProviderTest {
|
||||
@Test
|
||||
fun `given another epub 3 opf when getting book metadata then metadata patch is valid`() {
|
||||
val opf = ClassPathResource("epub/Die Drei 3.opf")
|
||||
every { mockExtractor.getPackageFile(any()) } returns opf.file.readText()
|
||||
mockkStatic(::getPackageFile)
|
||||
every { getPackageFile(any()) } returns opf.file.readText()
|
||||
|
||||
val patch = epubMetadataProvider.getBookMetadataFromBook(BookWithMedia(book, media))
|
||||
|
||||
@ -99,7 +101,8 @@ class EpubMetadataProviderTest {
|
||||
@Test
|
||||
fun `given epub 2 opf when getting book metadata then metadata patch is valid`() {
|
||||
val opf = ClassPathResource("epub/1979.opf")
|
||||
every { mockExtractor.getPackageFile(any()) } returns opf.file.readText()
|
||||
mockkStatic(::getPackageFile)
|
||||
every { getPackageFile(any()) } returns opf.file.readText()
|
||||
|
||||
val patch = epubMetadataProvider.getBookMetadataFromBook(BookWithMedia(book, media))
|
||||
|
||||
@ -124,7 +127,8 @@ class EpubMetadataProviderTest {
|
||||
@Test
|
||||
fun `given epub 3 opf when getting series metadata then metadata patch is valid`() {
|
||||
val opf = ClassPathResource("epub/Panik im Paradies.opf")
|
||||
every { mockExtractor.getPackageFile(any()) } returns opf.file.readText()
|
||||
mockkStatic(::getPackageFile)
|
||||
every { getPackageFile(any()) } returns opf.file.readText()
|
||||
|
||||
val patch = epubMetadataProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), library)
|
||||
|
||||
@ -141,7 +145,8 @@ class EpubMetadataProviderTest {
|
||||
@Test
|
||||
fun `given another epub 3 opf when getting series metadata then metadata patch is valid`() {
|
||||
val opf = ClassPathResource("epub/Die Drei 3.opf")
|
||||
every { mockExtractor.getPackageFile(any()) } returns opf.file.readText()
|
||||
mockkStatic(::getPackageFile)
|
||||
every { getPackageFile(any()) } returns opf.file.readText()
|
||||
|
||||
val patch = epubMetadataProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), library)
|
||||
|
||||
@ -158,7 +163,8 @@ class EpubMetadataProviderTest {
|
||||
@Test
|
||||
fun `given epub 2 opf when getting series metadata then metadata patch is valid`() {
|
||||
val opf = ClassPathResource("epub/1979.opf")
|
||||
every { mockExtractor.getPackageFile(any()) } returns opf.file.readText()
|
||||
mockkStatic(::getPackageFile)
|
||||
every { getPackageFile(any()) } returns opf.file.readText()
|
||||
|
||||
val patch = epubMetadataProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), library)
|
||||
|
||||
|
207
komga/src/test/resources/epub/clash.opf
Normal file
207
komga/src/test/resources/epub/clash.opf
Normal file
@ -0,0 +1,207 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<package version="2.0" unique-identifier="PrimaryID" xmlns="http://www.idpf.org/2007/opf">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
|
||||
<dc:identifier id="PrimaryID" opf:scheme="ISBN">978-0-553-89784-5</dc:identifier>
|
||||
<dc:title>A Clash of Kings</dc:title>
|
||||
<dc:rights>Copyright © 1996 by George R.R. Martin</dc:rights>
|
||||
<dc:creator opf:file-as="Martin, George R. R." opf:role="aut">George R. R. Martin</dc:creator>
|
||||
<dc:description><p></dc:description>
|
||||
<dc:publisher>Random House Publishing Group</dc:publisher>
|
||||
<dc:date opf:event="publication">2011-03-22</dc:date>
|
||||
<dc:language>en</dc:language>
|
||||
<meta name="cover" content="b02-cover-image" />
|
||||
<meta content="1.1" name="epubcheckversion" />
|
||||
<meta content="2011-03-15" name="epubcheckdate" />
|
||||
<meta content="0.4.0" name="Sigil version" />
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
|
||||
<item id="b02-css" href="Styles/Mart_9780553897852_epub_css_r1.css" media-type="text/css"/>
|
||||
<item id="b02-cvi" href="Text/Mart_9780553897852_epub_cvi_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-tp" href="Text/Mart_9780553897852_epub_tp_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-toc" href="Text/Mart_9780553897852_epub_toc_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-ded" href="Text/Mart_9780553897852_epub_ded_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-fm1" href="Text/Mart_9780553897852_epub_fm1_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-fm2" href="Text/Mart_9780553897852_epub_fm2_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-prl" href="Text/Mart_9780553897852_epub_prl_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c01" href="Text/Mart_9780553897852_epub_c01_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c02" href="Text/Mart_9780553897852_epub_c02_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c03" href="Text/Mart_9780553897852_epub_c03_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c04" href="Text/Mart_9780553897852_epub_c04_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c05" href="Text/Mart_9780553897852_epub_c05_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c06" href="Text/Mart_9780553897852_epub_c06_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c07" href="Text/Mart_9780553897852_epub_c07_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c08" href="Text/Mart_9780553897852_epub_c08_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c09" href="Text/Mart_9780553897852_epub_c09_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c10" href="Text/Mart_9780553897852_epub_c10_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c11" href="Text/Mart_9780553897852_epub_c11_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c12" href="Text/Mart_9780553897852_epub_c12_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c13" href="Text/Mart_9780553897852_epub_c13_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c14" href="Text/Mart_9780553897852_epub_c14_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c15" href="Text/Mart_9780553897852_epub_c15_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c16" href="Text/Mart_9780553897852_epub_c16_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c17" href="Text/Mart_9780553897852_epub_c17_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c18" href="Text/Mart_9780553897852_epub_c18_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c19" href="Text/Mart_9780553897852_epub_c19_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c20" href="Text/Mart_9780553897852_epub_c20_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c21" href="Text/Mart_9780553897852_epub_c21_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c22" href="Text/Mart_9780553897852_epub_c22_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c23" href="Text/Mart_9780553897852_epub_c23_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c24" href="Text/Mart_9780553897852_epub_c24_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c25" href="Text/Mart_9780553897852_epub_c25_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c26" href="Text/Mart_9780553897852_epub_c26_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c27" href="Text/Mart_9780553897852_epub_c27_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c28" href="Text/Mart_9780553897852_epub_c28_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c29" href="Text/Mart_9780553897852_epub_c29_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c30" href="Text/Mart_9780553897852_epub_c30_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c31" href="Text/Mart_9780553897852_epub_c31_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c32" href="Text/Mart_9780553897852_epub_c32_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c33" href="Text/Mart_9780553897852_epub_c33_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c34" href="Text/Mart_9780553897852_epub_c34_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c35" href="Text/Mart_9780553897852_epub_c35_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c36" href="Text/Mart_9780553897852_epub_c36_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c37" href="Text/Mart_9780553897852_epub_c37_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c38" href="Text/Mart_9780553897852_epub_c38_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c39" href="Text/Mart_9780553897852_epub_c39_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c40" href="Text/Mart_9780553897852_epub_c40_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c41" href="Text/Mart_9780553897852_epub_c41_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c42" href="Text/Mart_9780553897852_epub_c42_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c43" href="Text/Mart_9780553897852_epub_c43_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c44" href="Text/Mart_9780553897852_epub_c44_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c45" href="Text/Mart_9780553897852_epub_c45_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c46" href="Text/Mart_9780553897852_epub_c46_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c47" href="Text/Mart_9780553897852_epub_c47_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c48" href="Text/Mart_9780553897852_epub_c48_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c49" href="Text/Mart_9780553897852_epub_c49_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c50" href="Text/Mart_9780553897852_epub_c50_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c51" href="Text/Mart_9780553897852_epub_c51_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c52" href="Text/Mart_9780553897852_epub_c52_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c53" href="Text/Mart_9780553897852_epub_c53_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c54" href="Text/Mart_9780553897852_epub_c54_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c55" href="Text/Mart_9780553897852_epub_c55_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c56" href="Text/Mart_9780553897852_epub_c56_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c57" href="Text/Mart_9780553897852_epub_c57_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c58" href="Text/Mart_9780553897852_epub_c58_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c59" href="Text/Mart_9780553897852_epub_c59_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c60" href="Text/Mart_9780553897852_epub_c60_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c61" href="Text/Mart_9780553897852_epub_c61_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c62" href="Text/Mart_9780553897852_epub_c62_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c63" href="Text/Mart_9780553897852_epub_c63_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c64" href="Text/Mart_9780553897852_epub_c64_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c65" href="Text/Mart_9780553897852_epub_c65_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c66" href="Text/Mart_9780553897852_epub_c66_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c67" href="Text/Mart_9780553897852_epub_c67_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c68" href="Text/Mart_9780553897852_epub_c68_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-c69" href="Text/Mart_9780553897852_epub_c69_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-app" href="Text/Mart_9780553897852_epub_app_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-bm" href="Text/Mart_9780553897852_epub_bm_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-ack" href="Text/Mart_9780553897852_epub_ack_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-cop" href="Text/Mart_9780553897852_epub_cop_r1.htm" media-type="application/xhtml+xml"/>
|
||||
<item id="b02-cover-image" href="Images/Mart_9780553897852_epub_cvi_r1.jpg" media-type="image/jpeg"/>
|
||||
<item id="b02-id2182224" href="Images/Mart_9780553897852_epub_a01_r1.jpg" media-type="image/jpeg"/>
|
||||
<item id="b02-id2235231" href="Images/Mart_9780553897852_epub_a02_r1.jpg" media-type="image/jpeg"/>
|
||||
<item id="b02-id1208082" href="Images/Mart_9780553897852_epub_a03_r1.jpg" media-type="image/jpeg"/>
|
||||
<item id="b02-id1190433" href="Images/Mart_9780553897852_epub_a04_r1.jpg" media-type="image/jpeg"/>
|
||||
<item id="b02-id1344120" href="Images/Mart_9780553897852_epub_a05_r1.jpg" media-type="image/jpeg"/>
|
||||
<item id="b02-id2517717" href="Images/Mart_9780553897852_epub_a06_r1.jpg" media-type="image/jpeg"/>
|
||||
<item id="b02-id1233482" href="Images/Mart_9780553897852_epub_a07_r1.jpg" media-type="image/jpeg"/>
|
||||
<item id="b02-id1491108" href="Images/Mart_9780553897852_epub_a08_r1.jpg" media-type="image/jpeg"/>
|
||||
<item id="b02-id1500203" href="Images/Mart_9780553897852_epub_a09_r1.jpg" media-type="image/jpeg"/>
|
||||
<item id="b02-id1184086" href="Images/Mart_9780553897852_epub_a10_r1.jpg" media-type="image/jpeg"/>
|
||||
<item id="b02-id1371281" href="Images/Mart_9780553897852_epub_a11_r1.jpg" media-type="image/jpeg"/>
|
||||
<item id="b02-id1219283" href="Images/Mart_9780553897852_epub_a12_r1.jpg" media-type="image/jpeg"/>
|
||||
<item id="b02-id2384769" href="Images/Mart_9780553897852_epub_a13_r1.jpg" media-type="image/jpeg"/>
|
||||
<item id="b02-id2222502" href="Images/Mart_9780553897852_epub_a14_r1.jpg" media-type="image/jpeg"/>
|
||||
<item id="b02-id2545288" href="Images/Mart_9780553897852_epub_a15_r1.jpg" media-type="image/jpeg"/>
|
||||
<item id="b02-id2238633" href="Images/Mart_9780553897852_epub_a16_r1.jpg" media-type="image/jpeg"/>
|
||||
<item id="b02-id2277767" href="Images/Mart_9780553897852_epub_a17_r1.jpg" media-type="image/jpeg"/>
|
||||
<item id="page-template.xpgt" href="Styles/page-template.xpgt" media-type="application/vnd.adobe-page-template+xml"/>
|
||||
</manifest>
|
||||
<spine toc="ncx">
|
||||
<itemref idref="b02-cvi" linear="yes"/>
|
||||
<itemref idref="b02-tp"/>
|
||||
<itemref idref="b02-cop"/>
|
||||
<itemref idref="b02-toc"/>
|
||||
<itemref idref="b02-ded"/>
|
||||
<itemref idref="b02-fm1"/>
|
||||
<itemref idref="b02-fm2"/>
|
||||
<itemref idref="b02-prl"/>
|
||||
<itemref idref="b02-c01"/>
|
||||
<itemref idref="b02-c02"/>
|
||||
<itemref idref="b02-c03"/>
|
||||
<itemref idref="b02-c04"/>
|
||||
<itemref idref="b02-c05"/>
|
||||
<itemref idref="b02-c06"/>
|
||||
<itemref idref="b02-c07"/>
|
||||
<itemref idref="b02-c08"/>
|
||||
<itemref idref="b02-c09"/>
|
||||
<itemref idref="b02-c10"/>
|
||||
<itemref idref="b02-c11"/>
|
||||
<itemref idref="b02-c12"/>
|
||||
<itemref idref="b02-c13"/>
|
||||
<itemref idref="b02-c14"/>
|
||||
<itemref idref="b02-c15"/>
|
||||
<itemref idref="b02-c16"/>
|
||||
<itemref idref="b02-c17"/>
|
||||
<itemref idref="b02-c18"/>
|
||||
<itemref idref="b02-c19"/>
|
||||
<itemref idref="b02-c20"/>
|
||||
<itemref idref="b02-c21"/>
|
||||
<itemref idref="b02-c22"/>
|
||||
<itemref idref="b02-c23"/>
|
||||
<itemref idref="b02-c24"/>
|
||||
<itemref idref="b02-c25"/>
|
||||
<itemref idref="b02-c26"/>
|
||||
<itemref idref="b02-c27"/>
|
||||
<itemref idref="b02-c28"/>
|
||||
<itemref idref="b02-c29"/>
|
||||
<itemref idref="b02-c30"/>
|
||||
<itemref idref="b02-c31"/>
|
||||
<itemref idref="b02-c32"/>
|
||||
<itemref idref="b02-c33"/>
|
||||
<itemref idref="b02-c34"/>
|
||||
<itemref idref="b02-c35"/>
|
||||
<itemref idref="b02-c36"/>
|
||||
<itemref idref="b02-c37"/>
|
||||
<itemref idref="b02-c38"/>
|
||||
<itemref idref="b02-c39"/>
|
||||
<itemref idref="b02-c40"/>
|
||||
<itemref idref="b02-c41"/>
|
||||
<itemref idref="b02-c42"/>
|
||||
<itemref idref="b02-c43"/>
|
||||
<itemref idref="b02-c44"/>
|
||||
<itemref idref="b02-c45"/>
|
||||
<itemref idref="b02-c46"/>
|
||||
<itemref idref="b02-c47"/>
|
||||
<itemref idref="b02-c48"/>
|
||||
<itemref idref="b02-c49"/>
|
||||
<itemref idref="b02-c50"/>
|
||||
<itemref idref="b02-c51"/>
|
||||
<itemref idref="b02-c52"/>
|
||||
<itemref idref="b02-c53"/>
|
||||
<itemref idref="b02-c54"/>
|
||||
<itemref idref="b02-c55"/>
|
||||
<itemref idref="b02-c56"/>
|
||||
<itemref idref="b02-c57"/>
|
||||
<itemref idref="b02-c58"/>
|
||||
<itemref idref="b02-c59"/>
|
||||
<itemref idref="b02-c60"/>
|
||||
<itemref idref="b02-c61"/>
|
||||
<itemref idref="b02-c62"/>
|
||||
<itemref idref="b02-c63"/>
|
||||
<itemref idref="b02-c64"/>
|
||||
<itemref idref="b02-c65"/>
|
||||
<itemref idref="b02-c66"/>
|
||||
<itemref idref="b02-c67"/>
|
||||
<itemref idref="b02-c68"/>
|
||||
<itemref idref="b02-c69"/>
|
||||
<itemref idref="b02-app"/>
|
||||
<itemref idref="b02-bm"/>
|
||||
<itemref idref="b02-ack"/>
|
||||
</spine>
|
||||
<guide>
|
||||
<reference type="toc" title="Table Of Contents" href="Text/Mart_9780553897852_epub_toc_r1.htm"/>
|
||||
<reference type="text" title="Text" href="Text/Mart_9780553897852_epub_prl_r1.htm"/>
|
||||
<reference type="cover" title="Cover" href="Text/Mart_9780553897852_epub_cvi_r1.htm"/>
|
||||
</guide>
|
||||
</package>
|
49
komga/src/test/resources/epub/nav.xhtml
Normal file
49
komga/src/test/resources/epub/nav.xhtml
Normal file
@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html xmlns:epub="http://www.idpf.org/2007/ops" xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
|
||||
<head>
|
||||
<meta http-equiv="default-style" content="text/html; charset=UTF-8"/>
|
||||
<title>Best Bear Ever</title>
|
||||
<link rel="stylesheet" href="css/stylesheet.css" type="text/css"/>
|
||||
</head>
|
||||
<body>
|
||||
<nav id="toc" epub:type="toc">
|
||||
<h1 class="toc-title">Contents</h1>
|
||||
<ol epub:type="list">
|
||||
<li id="cover"><a href="cover.xhtml">Cover</a></li>
|
||||
<li id="titlepage"><a href="titlepage.xhtml">Title Page</a></li>
|
||||
<li id="toc-copyright"><a href="copyright.xhtml">Copyright</a></li>
|
||||
<li><a href="toc.xhtml">Table of Contents</a></li>
|
||||
<li><span>An unlinked heading</span></li>
|
||||
<li id="toc-introduction"><a href="introduction.xhtml">Introduction</a>
|
||||
<ol>
|
||||
<li id="toc-chapter001"><a href="chapter 001.xhtml">Spring</a></li>
|
||||
<li id="toc-chapter002"><a href="chapter%20027.xhtml">Summer</a></li>
|
||||
<li id="toc-chapter003"><a href="chapter053.xhtml">Fall</a></li>
|
||||
<li id="toc-chapter004"><a href="chapter079.xhtml">Winter</a></li>
|
||||
</ol>
|
||||
</li>
|
||||
<li id="toc-acknowledgements"><a href="acknowledgements.xhtml">Acknowledgments</a></li>
|
||||
</ol>
|
||||
</nav>
|
||||
<nav epub:type="landmarks" class="hidden-tag" hidden="hidden">
|
||||
<h1>Navigation</h1>
|
||||
<ol epub:type="list">
|
||||
<li><a epub:type="cover" href="cover.xhtml#coverimage">Begin Reading</a></li>
|
||||
<li><a epub:type="toc" href="toc.xhtml">Table of Contents</a></li>
|
||||
</ol>
|
||||
</nav>
|
||||
<nav role="doc-pagelist" aria-label="Page list" epub:type="page-list">
|
||||
<h2>Pagebreaks of the print version</h2>
|
||||
<ol>
|
||||
<li><a href="xhtml/cover.xhtml">Cover Page</a></li>
|
||||
<li><a href="xhtml/title.xhtml#pg_iii">iii</a></li>
|
||||
<li><a href="xhtml/chapter1.xhtml#pg_1">1</a></li>
|
||||
<li><a href="xhtml/chapter1.xhtml#pg_2">2</a></li>
|
||||
<li><a href="xhtml/acknowledgments.xhtml#pg_107">107</a></li>
|
||||
<li><a href="xhtml/adcard.xhtml#pg_ii">ii</a></li>
|
||||
<li><a href="xhtml/abouttheauthor.xhtml#pg_109">109</a></li>
|
||||
<li><a href="xhtml/copyright.xhtml#pg_iv">iv</a></li>
|
||||
</ol>
|
||||
</nav>
|
||||
</body>
|
||||
</html>
|
158
komga/src/test/resources/epub/toc.ncx
Normal file
158
komga/src/test/resources/epub/toc.ncx
Normal file
@ -0,0 +1,158 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no" ?><!DOCTYPE ncx PUBLIC "-//NISO//DTD ncx 2005-1//EN"
|
||||
"http://www.daisy.org/z3986/2005/ncx-2005-1.dtd">
|
||||
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1" xml:lang="en-US">
|
||||
<head>
|
||||
<meta content="978-0-553-89784-5" name="dtb:uid"/>
|
||||
<meta content="2" name="dtb:depth"/>
|
||||
<meta content="0" name="dtb:totalPageCount"/>
|
||||
<meta content="0" name="dtb:maxPageNumber"/>
|
||||
</head>
|
||||
<docTitle>
|
||||
<text>A Clash of Kings</text>
|
||||
</docTitle>
|
||||
<docAuthor>
|
||||
<text>George R. R. Martin</text>
|
||||
</docAuthor>
|
||||
<navMap>
|
||||
<navPoint id="b02" playOrder="95">
|
||||
<navLabel>
|
||||
<text>COVER</text>
|
||||
</navLabel>
|
||||
<content src="Text/Mart_9780553897852_epub_cvi_r1.htm#b02-cvi"/>
|
||||
</navPoint>
|
||||
<navPoint id="b02-c69" playOrder="171">
|
||||
<navLabel>
|
||||
<text>BRAN</text>
|
||||
</navLabel>
|
||||
<content src="Text/Mart_9780553897852_epub_c69_r1.htm"/>
|
||||
</navPoint>
|
||||
<navPoint id="b02-app" playOrder="172">
|
||||
<navLabel>
|
||||
<text>APPENDIX</text>
|
||||
</navLabel>
|
||||
<content src="Text/Mart_9780553897852_epub_app_r1.htm"/>
|
||||
<navPoint id="b02-apps01.00" playOrder="173">
|
||||
<navLabel>
|
||||
<text>THE KINGS AND THEIR COURTS</text>
|
||||
</navLabel>
|
||||
<content src="Text/Mart_9780553897852_epub_app_r1.htm#apps01.00"/>
|
||||
</navPoint>
|
||||
<navPoint id="b02-apps01.01" playOrder="174">
|
||||
<navLabel>
|
||||
<text>THE KING ON THE IRON THRONE</text>
|
||||
</navLabel>
|
||||
<content src="Text/Mart_9780553897852_epub_app_r1.htm#apps01.01"/>
|
||||
</navPoint>
|
||||
<navPoint id="b02-apps01.02" playOrder="175">
|
||||
<navLabel>
|
||||
<text>THE KING IN THE NARROW SEA</text>
|
||||
</navLabel>
|
||||
<content src="Text/Mart_9780553897852_epub_app_r1.htm#apps01.02"/>
|
||||
</navPoint>
|
||||
<navPoint id="b02-apps01.03" playOrder="176">
|
||||
<navLabel>
|
||||
<text>THE KING IN HIGHGARDEN</text>
|
||||
</navLabel>
|
||||
<content src="Text/Mart_9780553897852_epub_app_r1.htm#apps01.03"/>
|
||||
</navPoint>
|
||||
<navPoint id="b02-apps01.04" playOrder="177">
|
||||
<navLabel>
|
||||
<text>THE KING IN THE NORTH</text>
|
||||
</navLabel>
|
||||
<content src="Text/Mart_9780553897852_epub_app_r1.htm#apps01.04"/>
|
||||
</navPoint>
|
||||
<navPoint id="b02-apps01.05" playOrder="178">
|
||||
<navLabel>
|
||||
<text>THE QUEEN ACROSS THE WATER</text>
|
||||
</navLabel>
|
||||
<content src="Text/Mart_9780553897852_epub_app_r1.htm#apps01.05"/>
|
||||
<navPoint id="b02-apps01.00" playOrder="173">
|
||||
<navLabel>
|
||||
<text>Another level</text>
|
||||
</navLabel>
|
||||
<content src="Text/Mart_9780553897852 epub_app_r1.htm#apps01.06"/>
|
||||
</navPoint>
|
||||
<navPoint id="b02-apps01.01" playOrder="174">
|
||||
<navLabel>
|
||||
<text>Yet another level</text>
|
||||
</navLabel>
|
||||
<content src="Text/Mart_9780553897852%20epub_app_r1.htm#apps01.07"/>
|
||||
</navPoint>
|
||||
</navPoint>
|
||||
</navPoint>
|
||||
<navPoint id="b02-ack" playOrder="188">
|
||||
<navLabel>
|
||||
<text>ACKNOWLEDGMENTS</text>
|
||||
</navLabel>
|
||||
<content src="Text/Mart_9780553897852_epub_ack_r1.htm"/>
|
||||
</navPoint>
|
||||
</navMap>
|
||||
<pageList>
|
||||
<pageTarget type="normal" id="id_cov" value="Cover Page">
|
||||
<navLabel>
|
||||
<text>Cover Page</text>
|
||||
</navLabel>
|
||||
<content src="xhtml/cover.xhtml"/>
|
||||
</pageTarget>
|
||||
<pageTarget type="normal" id="id_iii" value="iii">
|
||||
<navLabel>
|
||||
<text>iii</text>
|
||||
</navLabel>
|
||||
<content src="xhtml/title.xhtml#pg_iii"/>
|
||||
</pageTarget>
|
||||
<pageTarget type="normal" id="id_v" value="v">
|
||||
<navLabel>
|
||||
<text>v</text>
|
||||
</navLabel>
|
||||
<content src="xhtml/dedication.xhtml#pg_v"/>
|
||||
</pageTarget>
|
||||
<pageTarget type="normal" id="id_vii" value="vii">
|
||||
<navLabel>
|
||||
<text>vii</text>
|
||||
</navLabel>
|
||||
<content src="xhtml/formoreinformation.xhtml#pg_vii"/>
|
||||
</pageTarget>
|
||||
<pageTarget type="normal" id="id_viii" value="viii">
|
||||
<navLabel>
|
||||
<text>viii</text>
|
||||
</navLabel>
|
||||
<content src="xhtml/formoreinformation.xhtml#pg_viii"/>
|
||||
</pageTarget>
|
||||
<pageTarget type="normal" id="id_ix" value="ix">
|
||||
<navLabel>
|
||||
<text>ix</text>
|
||||
</navLabel>
|
||||
<content src="xhtml/formoreinformation.xhtml#pg_ix"/>
|
||||
</pageTarget>
|
||||
<pageTarget type="normal" id="id_x" value="x">
|
||||
<navLabel>
|
||||
<text>x</text>
|
||||
</navLabel>
|
||||
<content src="xhtml/formoreinformation.xhtml#pg_x"/>
|
||||
</pageTarget>
|
||||
<pageTarget type="normal" id="id_xi" value="xi">
|
||||
<navLabel>
|
||||
<text>xi</text>
|
||||
</navLabel>
|
||||
<content src="xhtml/formoreinformation.xhtml#pg_xi"/>
|
||||
</pageTarget>
|
||||
<pageTarget type="normal" id="id_1" value="1">
|
||||
<navLabel>
|
||||
<text>1</text>
|
||||
</navLabel>
|
||||
<content src="xhtml/chapter1.xhtml#pg_1"/>
|
||||
</pageTarget>
|
||||
<pageTarget type="normal" id="id_2" value="2">
|
||||
<navLabel>
|
||||
<text>2</text>
|
||||
</navLabel>
|
||||
<content src="xhtml/chapter1.xhtml#pg_2"/>
|
||||
</pageTarget>
|
||||
<pageTarget type="normal" id="id_3" value="3">
|
||||
<navLabel>
|
||||
<text>3</text>
|
||||
</navLabel>
|
||||
<content src="xhtml/chapter1.xhtml#pg_3"/>
|
||||
</pageTarget>
|
||||
</pageList>
|
||||
</ncx>
|
Loading…
Reference in New Issue
Block a user