From eb8a6442346a4e8faf300093b5e27f8e0375414b Mon Sep 17 00:00:00 2001 From: Gauthier Roebroeck Date: Fri, 1 Dec 2023 16:25:12 +0800 Subject: [PATCH] feat(api): add positions endpoint to get pre-computed positions of epub books --- .../V20231201115954__media_extension_blob.sql | 13 +++ .../komga/domain/model/MediaExtension.kt | 22 ++++ .../gotson/komga/domain/model/R2Locator.kt | 88 ++++++++++++++ .../domain/persistence/MediaRepository.kt | 2 + .../komga/domain/service/BookAnalyzer.kt | 13 ++- .../infrastructure/jooq/main/MediaDao.kt | 65 ++++++++--- .../mediacontainer/epub/Epub.kt | 3 +- .../mediacontainer/epub/EpubExtractor.kt | 42 ++++++- .../mediacontainer/epub/EpubManifest.kt | 3 + .../komga/interfaces/api/WebPubGenerator.kt | 58 +++++----- .../komga/interfaces/api/dto/Constants.kt | 2 + .../interfaces/api/rest/BookController.kt | 34 ++++++ .../interfaces/api/rest/dto/R2Positions.kt | 8 ++ .../komga/domain/model/ProxyExtensionTest.kt | 34 ++++++ .../infrastructure/jooq/main/MediaDaoTest.kt | 108 +++++++++++++++--- 15 files changed, 435 insertions(+), 60 deletions(-) create mode 100644 komga/src/flyway/resources/db/migration/sqlite/V20231201115954__media_extension_blob.sql create mode 100644 komga/src/main/kotlin/org/gotson/komga/domain/model/R2Locator.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/R2Positions.kt create mode 100644 komga/src/test/kotlin/org/gotson/komga/domain/model/ProxyExtensionTest.kt diff --git a/komga/src/flyway/resources/db/migration/sqlite/V20231201115954__media_extension_blob.sql b/komga/src/flyway/resources/db/migration/sqlite/V20231201115954__media_extension_blob.sql new file mode 100644 index 000000000..8bb13334c --- /dev/null +++ b/komga/src/flyway/resources/db/migration/sqlite/V20231201115954__media_extension_blob.sql @@ -0,0 +1,13 @@ +update MEDIA +set EXTENSION_VALUE = null; + +update media +set STATUS = 'OUTDATED' +where MEDIA_TYPE = 'application/epub+zip' + and STATUS = 'READY'; + +ALTER TABLE MEDIA + RENAME COLUMN EXTENSION_VALUE to _UNUSED; + +alter table MEDIA + add column EXTENSION_VALUE_BLOB blob NULL; diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaExtension.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaExtension.kt index 550f517d7..b878014c1 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaExtension.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaExtension.kt @@ -1,9 +1,31 @@ package org.gotson.komga.domain.model +import kotlin.reflect.KClass +import kotlin.reflect.full.isSubclassOf + interface MediaExtension +class ProxyExtension private constructor( + val extensionClassName: String, +) : MediaExtension { + + companion object { + fun of(extensionClass: String?): ProxyExtension? = + extensionClass?.let { + val kClass = Class.forName(extensionClass).kotlin + if (kClass.qualifiedName != MediaExtension::class.qualifiedName && kClass.isSubclassOf(MediaExtension::class)) ProxyExtension(extensionClass) + else null + } + } + + inline fun proxyForType(): Boolean = T::class.qualifiedName == extensionClassName + fun proxyForType(clazz: KClass): Boolean = clazz.qualifiedName == extensionClassName +} + data class MediaExtensionEpub( val toc: List = emptyList(), val landmarks: List = emptyList(), val pageList: List = emptyList(), + val isFixedLayout: Boolean = false, + val positions: List = emptyList(), ) : MediaExtension diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/R2Locator.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/R2Locator.kt new file mode 100644 index 000000000..bcaf0fb04 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/R2Locator.kt @@ -0,0 +1,88 @@ +package org.gotson.komga.domain.model + +import com.fasterxml.jackson.annotation.JsonInclude + +/** + * Locators are meant to provide a precise location in a publication in a format that can be stored and shared. + * + * There are many different use cases for locators: + * - reporting and saving the current progression + * - bookmarks + * - highlights & annotations + * - search results + * - human-readable (as-in shareable) references + * - jumping to a location + * - enhancing a table of contents + * + * Each locator must contain a reference to a resource in a publication (href and type). href must not point to the fragment of a resource. + * + * It may also contain: + * - a title (`title`) + * - one or more locations in a resource (grouped together in locations) + * - one or more text references, if the resource is a document (`text`) + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +data class R2Locator( + /** + * The URI of the resource that the Locator Object points to. + */ + val href: String, + /** + * The media type of the resource that the Locator Object points to. + */ + val type: String, + /** + * The title of the chapter or section which is more relevant in the context of this locator. + */ + val title: String? = null, + /** + * One or more alternative expressions of the location. + */ + val locations: Location? = null, + /** + * Textual context of the locator. + */ + val text: Text? = null, +) { + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + data class Location( + /** + * Contains one or more fragment in the resource referenced by the Locator Object. + */ + val fragments: List = emptyList(), + /** + * Progression in the resource expressed as a percentage. + * Between 0 and 1. + */ + val progression: Float? = null, + /** + * An index in the publication. + */ + val position: Int? = null, + /** + * Progression in the publication expressed as a percentage. + * Between 0 and 1. + */ + val totalProgression: Float? = null, + ) + + /** + * A Locator Text Object contains multiple text fragments, useful to give a context to the Locator or for highlights. + */ + @JsonInclude(JsonInclude.Include.NON_EMPTY) + data class Text( + /** + * The text after the locator. + */ + val after: String? = null, + /** + * The text before the locator. + */ + val before: String? = null, + /** + * The text at the locator. + */ + val highlight: String? = null, + ) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/MediaRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/MediaRepository.kt index 92c9977e7..35ece8396 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/MediaRepository.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/MediaRepository.kt @@ -1,6 +1,7 @@ package org.gotson.komga.domain.persistence import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaExtension interface MediaRepository { fun findById(bookId: String): Media @@ -10,6 +11,7 @@ interface MediaRepository { fun getPagesSize(bookId: String): Int fun getPagesSizes(bookIds: Collection): Collection> + fun findExtensionByIdOrNull(bookId: String): MediaExtension? fun insert(media: Media) fun insert(medias: Collection) diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookAnalyzer.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookAnalyzer.kt index f429f4b39..92d1e229c 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookAnalyzer.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookAnalyzer.kt @@ -124,7 +124,18 @@ class BookAnalyzer( 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)) + return Media( + status = Media.Status.READY, + files = manifest.resources, + pageCount = manifest.pageCount, + extension = MediaExtensionEpub( + toc = manifest.toc, + landmarks = manifest.landmarks, + pageList = manifest.pageList, + isFixedLayout = manifest.isFixedLayout, + positions = manifest.positions, + ), + ) } private fun analyzePdf(book: Book, analyzeDimensions: Boolean): Media { diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/MediaDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/MediaDao.kt index 04713bbf8..76ca35e27 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/MediaDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/MediaDao.kt @@ -7,6 +7,7 @@ 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.model.ProxyExtension import org.gotson.komga.domain.persistence.MediaRepository import org.gotson.komga.infrastructure.jooq.insertTempStrings import org.gotson.komga.infrastructure.jooq.selectTempStrings @@ -20,8 +21,11 @@ import org.jooq.impl.DSL import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component import org.springframework.transaction.annotation.Transactional +import java.io.ByteArrayOutputStream import java.time.LocalDateTime import java.time.ZoneId +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream private val logger = KotlinLogging.logger {} @@ -37,7 +41,17 @@ class MediaDao( private val f = Tables.MEDIA_FILE private val b = Tables.BOOK - private val groupFields = arrayOf(*m.fields(), *p.fields()) + private val groupFields = arrayOf( + m.BOOK_ID, + m.MEDIA_TYPE, + m.STATUS, + m.CREATED_DATE, + m.LAST_MODIFIED_DATE, + m.COMMENT, + m.PAGE_COUNT, + m.EXTENSION_CLASS, + *p.fields(), + ) override fun findById(bookId: String): Media = find(dsl, bookId)!! @@ -45,6 +59,13 @@ class MediaDao( override fun findByIdOrNull(bookId: String): Media? = find(dsl, bookId) + override fun findExtensionByIdOrNull(bookId: String): MediaExtension? = + dsl.select(m.EXTENSION_CLASS, m.EXTENSION_VALUE_BLOB) + .from(m) + .where(m.BOOK_ID.eq(bookId)) + .fetchOne() + ?.map { deserializeExtension(it.get(m.EXTENSION_CLASS), it.get(m.EXTENSION_VALUE_BLOB)) } + override fun findAllBookIdsByLibraryIdAndMediaTypeAndWithMissingPageHash(libraryId: String, mediaTypes: Collection, pageHashing: Int): Collection { val pagesCount = DSL.count(p.BOOK_ID) val hashedCount = DSL.sum(DSL.`when`(p.FILE_HASH.eq(""), 0).otherwise(1)).cast(Int::class.java) @@ -114,7 +135,7 @@ class MediaDao( m.COMMENT, m.PAGE_COUNT, m.EXTENSION_CLASS, - m.EXTENSION_VALUE, + m.EXTENSION_VALUE_BLOB, ).values(null as String?, null, null, null, null, null, null), ).also { step -> chunk.forEach { media -> @@ -124,8 +145,8 @@ class MediaDao( media.mediaType, media.comment, media.pageCount, - media.extension?.let { it::class.qualifiedName }, - media.extension?.let { mapper.writeValueAsString(it) }, + media.extension?.let { if (it is ProxyExtension) null else it::class.qualifiedName }, + media.extension?.let { if (it is ProxyExtension) null else serializeExtension(it) }, ) } }.execute() @@ -207,8 +228,12 @@ 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) }) + .apply { + if (media.extension != null && media.extension !is ProxyExtension) { + set(m.EXTENSION_CLASS, media.extension::class.qualifiedName) + set(m.EXTENSION_VALUE_BLOB, serializeExtension(media.extension)) + } + } .set(m.LAST_MODIFIED_DATE, LocalDateTime.now(ZoneId.of("Z"))) .where(m.BOOK_ID.eq(media.bookId)) .execute() @@ -250,19 +275,33 @@ class MediaDao( pages = pages, pageCount = pageCount, files = files, - extension = deserializeExtension(extensionClass, extensionValue), + extension = ProxyExtension.of(extensionClass), 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 + fun serializeExtension(extension: MediaExtension): ByteArray? = + try { + ByteArrayOutputStream().use { baos -> + GZIPOutputStream(baos).use { gz -> + mapper.writeValue(gz, extension) + baos.toByteArray() + } + } } catch (e: Exception) { - logger.error(e) { "Could not deserialize media extension class: $extensionClass, value: $extensionValue" } + logger.error(e) { "Could not serialize media extension" } + null + } + + fun deserializeExtension(extensionClass: String?, extensionBlob: ByteArray?): MediaExtension? { + if (extensionClass == null || extensionBlob == null) return null + return try { + GZIPInputStream(extensionBlob.inputStream()).use { gz -> + mapper.readValue(gz, Class.forName(extensionClass)) as MediaExtension + } + } catch (e: Exception) { + logger.error(e) { "Could not deserialize media extension class: $extensionClass" } null } } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/Epub.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/Epub.kt index c3e7597f8..c7adaf1c2 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/Epub.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/Epub.kt @@ -4,6 +4,7 @@ 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 org.jsoup.parser.Parser import java.nio.file.Path import java.nio.file.Paths @@ -17,7 +18,7 @@ data class EpubPackage( inline fun 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 opfDoc = zip.getInputStream(zip.getEntry(opfFile)).use { Jsoup.parse(it, null, "", Parser.xmlParser()) } val opfDir = Paths.get(opfFile).parent block(EpubPackage(zip, opfDoc, opfDir, opfDoc.getManifest())) } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/EpubExtractor.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/EpubExtractor.kt index 3c8e6494b..2a36b1e64 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/EpubExtractor.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/EpubExtractor.kt @@ -4,10 +4,12 @@ import org.apache.commons.compress.archivers.ArchiveEntry import org.apache.commons.compress.archivers.zip.ZipFile import org.gotson.komga.domain.model.EpubTocEntry import org.gotson.komga.domain.model.MediaFile +import org.gotson.komga.domain.model.R2Locator import org.gotson.komga.domain.model.TypedBytes import org.springframework.stereotype.Service import java.nio.file.Path import kotlin.math.ceil +import kotlin.math.roundToInt @Service class EpubExtractor { @@ -44,12 +46,16 @@ class EpubExtractor { fun getManifest(path: Path): EpubManifest = path.epub { epub -> + val resources = getResources(epub) + val isFixedLayout = isFixedLayout(epub) EpubManifest( - resources = getResources(epub), + resources = resources, toc = getToc(epub), landmarks = getLandmarks(epub), pageList = getPageList(epub), pageCount = computePageCount(epub), + isFixedLayout = isFixedLayout, + positions = computePositions(resources, isFixedLayout), ) } @@ -86,6 +92,40 @@ class EpubExtractor { return epub.zip.entries.toList().filter { it.name in spine }.sumOf { ceil(it.compressedSize / 1024.0).toInt() } } + private fun isFixedLayout(epub: EpubPackage) = + epub.opfDoc.selectFirst("metadata > *|meta[property=rendition:layout]")?.text()?.ifBlank { null } == "pre-paginated" + + private fun computePositions(resources: List, isFixedLayout: Boolean): List { + val readingOrder = resources.filter { it.subType == MediaFile.SubType.EPUB_PAGE } + + var startPosition = 1 + val positions = if (isFixedLayout) { + readingOrder.map { + R2Locator( + href = it.fileName, + type = it.mediaType!!, + locations = R2Locator.Location(progression = 0F, position = startPosition++), + ) + } + } else { + readingOrder.flatMap { file -> + val positionCount = maxOf(1, ceil(file.fileSize!! / 1024.0).roundToInt()) + (0 until positionCount).map { p -> + R2Locator( + href = file.fileName, + type = file.mediaType!!, + locations = R2Locator.Location(progression = p.toFloat() / positionCount, position = startPosition++), + ) + } + } + } + + return positions.map { locator -> + val totalProgression = locator.locations?.position?.let { it.toFloat() / positions.size } + locator.copy(locations = locator.locations?.copy(totalProgression = totalProgression)) + } + } + private fun getToc(epub: EpubPackage): List { // Epub 3 epub.getNavResource()?.let { return processNav(it, Epub3Nav.TOC) } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/EpubManifest.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/EpubManifest.kt index 6341b5acd..6f62407fb 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/EpubManifest.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/mediacontainer/epub/EpubManifest.kt @@ -2,6 +2,7 @@ package org.gotson.komga.infrastructure.mediacontainer.epub import org.gotson.komga.domain.model.EpubTocEntry import org.gotson.komga.domain.model.MediaFile +import org.gotson.komga.domain.model.R2Locator data class EpubManifest( val resources: List, @@ -9,4 +10,6 @@ data class EpubManifest( val landmarks: List, val pageList: List, val pageCount: Int, + val isFixedLayout: Boolean, + val positions: List, ) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/WebPubGenerator.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/WebPubGenerator.kt index 23287eed0..722e48e08 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/WebPubGenerator.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/WebPubGenerator.kt @@ -6,7 +6,9 @@ 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.ProxyExtension import org.gotson.komga.domain.model.SeriesMetadata +import org.gotson.komga.domain.persistence.MediaRepository import org.gotson.komga.domain.service.BookAnalyzer import org.gotson.komga.infrastructure.image.ImageConverter import org.gotson.komga.infrastructure.image.ImageType @@ -40,10 +42,10 @@ import org.gotson.komga.domain.model.MediaType as KomgaMediaType @Service class WebPubGenerator( - @Qualifier("thumbnailType") - private val thumbnailType: ImageType, + @Qualifier("thumbnailType") private val thumbnailType: ImageType, private val imageConverter: ImageConverter, private val bookAnalyzer: BookAnalyzer, + private val mediaRepository: MediaRepository, ) { private val wpKnownRoles = listOf( "author", @@ -87,9 +89,7 @@ class WebPubGenerator( val pages = if (media.profile == MediaProfile.PDF) bookAnalyzer.getPdfPagesDynamic(media) else media.pages it.copy( mediaType = MEDIATYPE_DIVINA_JSON, - metadata = it.metadata - .withSeriesMetadata(seriesMetadata) - .copy(conformsTo = PROFILE_DIVINA), + metadata = it.metadata.withSeriesMetadata(seriesMetadata).copy(conformsTo = PROFILE_DIVINA), readingOrder = pages.mapIndexed { index: Int, page: BookPage -> WPLinkDto( href = uriBuilder.cloneBuilder().path("books/${bookDto.id}/pages/${index + 1}").toUriString(), @@ -116,9 +116,7 @@ class WebPubGenerator( return bookDto.toBasePublicationDto().let { it.copy( mediaType = MEDIATYPE_WEBPUB_JSON, - metadata = it.metadata - .withSeriesMetadata(seriesMetadata) - .copy(conformsTo = PROFILE_PDF), + metadata = it.metadata.withSeriesMetadata(seriesMetadata).copy(conformsTo = PROFILE_PDF), readingOrder = List(media.pageCount) { index: Int -> WPLinkDto( href = uriBuilder.cloneBuilder().path("books/${bookDto.id}/pages/${index + 1}/raw").toUriString(), @@ -132,26 +130,27 @@ class WebPubGenerator( fun toManifestEpub(bookDto: BookDto, media: Media, seriesMetadata: SeriesMetadata): WPPublicationDto { val uriBuilder = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("api", "v1") - val extension = media.extension as MediaExtensionEpub? + val extension = when { + media.extension is ProxyExtension && media.extension.proxyForType() -> mediaRepository.findExtensionByIdOrNull(media.bookId) as? MediaExtensionEpub + media.extension is MediaExtensionEpub -> media.extension + else -> null + } return bookDto.toBasePublicationDto().let { publication -> publication.copy( mediaType = MEDIATYPE_WEBPUB_JSON, - metadata = publication.metadata - .withSeriesMetadata(seriesMetadata) - .copy(conformsTo = PROFILE_EPUB), + 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, - ) - }, + 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(), @@ -193,17 +192,16 @@ class WebPubGenerator( ), ) - private fun WPMetadataDto.withSeriesMetadata(seriesMetadata: SeriesMetadata) = - copy( - language = seriesMetadata.language, - readingProgression = when (seriesMetadata.readingDirection) { - SeriesMetadata.ReadingDirection.LEFT_TO_RIGHT -> WPReadingProgressionDto.LTR - SeriesMetadata.ReadingDirection.RIGHT_TO_LEFT -> WPReadingProgressionDto.RTL - SeriesMetadata.ReadingDirection.VERTICAL -> WPReadingProgressionDto.TTB - SeriesMetadata.ReadingDirection.WEBTOON -> WPReadingProgressionDto.TTB - null -> null - }, - ) + private fun WPMetadataDto.withSeriesMetadata(seriesMetadata: SeriesMetadata) = copy( + language = seriesMetadata.language, + readingProgression = when (seriesMetadata.readingDirection) { + SeriesMetadata.ReadingDirection.LEFT_TO_RIGHT -> WPReadingProgressionDto.LTR + SeriesMetadata.ReadingDirection.RIGHT_TO_LEFT -> WPReadingProgressionDto.RTL + SeriesMetadata.ReadingDirection.VERTICAL -> WPReadingProgressionDto.TTB + SeriesMetadata.ReadingDirection.WEBTOON -> WPReadingProgressionDto.TTB + null -> null + }, + ) private fun WPMetadataDto.withAuthors(authors: List): WPMetadataDto { val groups = authors.groupBy({ it.role }, { it.name }) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/dto/Constants.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/dto/Constants.kt index e3ea637ff..be0127695 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/dto/Constants.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/dto/Constants.kt @@ -5,6 +5,7 @@ import org.springframework.http.MediaType const val MEDIATYPE_OPDS_JSON_VALUE = "application/opds+json" const val MEDIATYPE_DIVINA_JSON_VALUE = "application/divina+json" const val MEDIATYPE_WEBPUB_JSON_VALUE = "application/webpub+json" +const val MEDIATYPE_POSITION_LIST_JSON_VALUE = "application/vnd.readium.position-list+json" const val PROFILE_DIVINA = "https://readium.org/webpub-manifest/profiles/divina" const val PROFILE_EPUB = "https://readium.org/webpub-manifest/profiles/epub" @@ -13,3 +14,4 @@ const val PROFILE_PDF = "https://readium.org/webpub-manifest/profiles/pdf" val MEDIATYPE_OPDS_PUBLICATION_JSON = MediaType("application", "opds-publication+json") val MEDIATYPE_DIVINA_JSON = MediaType("application", "divina+json") val MEDIATYPE_WEBPUB_JSON = MediaType("application", "webpub+json") +val MEDIATYPE_POSITION_LIST_JSON = MediaType("application", "vnd.readium.position-list+json") diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt index f017d5e3c..3c417c746 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt @@ -23,6 +23,7 @@ import org.gotson.komga.domain.model.ImageConversionException import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.MarkSelectedPreference import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaExtensionEpub import org.gotson.komga.domain.model.MediaNotReadyException import org.gotson.komga.domain.model.MediaProfile import org.gotson.komga.domain.model.MediaUnsupportedException @@ -51,6 +52,8 @@ import org.gotson.komga.infrastructure.web.setCachePrivate import org.gotson.komga.interfaces.api.WebPubGenerator import org.gotson.komga.interfaces.api.checkContentRestriction import org.gotson.komga.interfaces.api.dto.MEDIATYPE_DIVINA_JSON_VALUE +import org.gotson.komga.interfaces.api.dto.MEDIATYPE_POSITION_LIST_JSON +import org.gotson.komga.interfaces.api.dto.MEDIATYPE_POSITION_LIST_JSON_VALUE import org.gotson.komga.interfaces.api.dto.MEDIATYPE_WEBPUB_JSON_VALUE import org.gotson.komga.interfaces.api.dto.WPPublicationDto import org.gotson.komga.interfaces.api.persistence.BookDtoRepository @@ -58,6 +61,7 @@ import org.gotson.komga.interfaces.api.rest.dto.BookDto import org.gotson.komga.interfaces.api.rest.dto.BookImportBatchDto import org.gotson.komga.interfaces.api.rest.dto.BookMetadataUpdateDto import org.gotson.komga.interfaces.api.rest.dto.PageDto +import org.gotson.komga.interfaces.api.rest.dto.R2Positions import org.gotson.komga.interfaces.api.rest.dto.ReadListDto import org.gotson.komga.interfaces.api.rest.dto.ReadProgressUpdateDto import org.gotson.komga.interfaces.api.rest.dto.ThumbnailBookDto @@ -707,6 +711,36 @@ class BookController( .body(bytes) } + @GetMapping( + value = ["api/v1/books/{bookId}/positions"], + produces = [MEDIATYPE_POSITION_LIST_JSON_VALUE], + ) + fun getPositions( + request: HttpServletRequest, + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + ): ResponseEntity = + bookRepository.findByIdOrNull(bookId)?.let { book -> + val media = mediaRepository.findById(book.id) + + if (ServletWebRequest(request).checkNotModified(getBookLastModified(media))) { + return ResponseEntity + .status(HttpStatus.NOT_MODIFIED) + .setNotModified(media) + .body(null) + } + + principal.user.checkContentRestriction(book) + + val extension = mediaRepository.findExtensionByIdOrNull(book.id) as? MediaExtensionEpub + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + ResponseEntity.ok() + .contentType(MEDIATYPE_POSITION_LIST_JSON) + .setNotModified(media) + .body(R2Positions(extension.positions.size, extension.positions)) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + @GetMapping( value = ["api/v1/books/{bookId}/manifest/epub"], produces = [MEDIATYPE_WEBPUB_JSON_VALUE], diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/R2Positions.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/R2Positions.kt new file mode 100644 index 000000000..263192304 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/R2Positions.kt @@ -0,0 +1,8 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import org.gotson.komga.domain.model.R2Locator + +data class R2Positions( + val total: Int, + val positions: List, +) diff --git a/komga/src/test/kotlin/org/gotson/komga/domain/model/ProxyExtensionTest.kt b/komga/src/test/kotlin/org/gotson/komga/domain/model/ProxyExtensionTest.kt new file mode 100644 index 000000000..4a0c4f8de --- /dev/null +++ b/komga/src/test/kotlin/org/gotson/komga/domain/model/ProxyExtensionTest.kt @@ -0,0 +1,34 @@ +package org.gotson.komga.domain.model + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class ProxyExtensionTest { + + @Test + fun `when creating proxy of MediaExtension class then it is created`() { + val proxy = ProxyExtension.of(MediaExtensionEpub::class.qualifiedName) + + assertThat(proxy).isNotNull + } + + @Test + fun `when creating proxy of MediaExtension interface then it is null`() { + val proxy = ProxyExtension.of(MediaExtension::class.qualifiedName) + + assertThat(proxy).isNull() + } + + @Test + fun `given proxy extension of class when checking against the class type then it returns true`() { + val proxy = ProxyExtension.of(MediaExtensionEpub::class.qualifiedName)!! + + assertThat(proxy.proxyForType()).isTrue() + assertThat(proxy.proxyForType()).isFalse() + assertThat(proxy.proxyForType()).isFalse() + + assertThat(proxy.proxyForType(MediaExtensionEpub::class)).isTrue() + assertThat(proxy.proxyForType(MediaExtension::class)).isFalse() + assertThat(proxy.proxyForType(Media::class)).isFalse() + } +} diff --git a/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/MediaDaoTest.kt b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/MediaDaoTest.kt index 19fb730d1..b08b21bd5 100644 --- a/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/MediaDaoTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/MediaDaoTest.kt @@ -9,6 +9,7 @@ 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.ProxyExtension import org.gotson.komga.domain.model.makeBook import org.gotson.komga.domain.model.makeLibrary import org.gotson.komga.domain.model.makeSeries @@ -77,10 +78,6 @@ class MediaDaoTest( ), ), 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, ) @@ -109,9 +106,6 @@ class MediaDaoTest( 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 @@ -127,6 +121,7 @@ class MediaDaoTest( assertThat(created.comment).isNull() assertThat(created.pages).isEmpty() assertThat(created.files).isEmpty() + assertThat(created.extension).isNull() } @Test @@ -141,9 +136,6 @@ class MediaDaoTest( ), ), 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, ) @@ -165,9 +157,6 @@ class MediaDaoTest( ), ), files = listOf(MediaFile("id.txt")), - extension = MediaExtensionEpub( - toc = listOf(EpubTocEntry("title", "href", listOf(EpubTocEntry("subtitle", "subhref")))), - ), comment = "comment2", ) } @@ -192,7 +181,6 @@ class MediaDaoTest( 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 @@ -224,6 +212,98 @@ class MediaDaoTest( assertThat(found).isInstanceOf(Exception::class.java) } + @Nested + inner class MediaExtension { + @Test + fun `given a media with extension when inserting then it is persisted`() { + val media = Media( + status = Media.Status.READY, + mediaType = "application/epub+zip", + extension = MediaExtensionEpub( + toc = listOf(EpubTocEntry("title", "href", listOf(EpubTocEntry("subtitle", "subhref")))), + landmarks = listOf(EpubTocEntry("title2", "href2", listOf(EpubTocEntry("subtitle2", "subhref2")))), + ), + bookId = book.id, + ) + + mediaDao.insert(media) + val created = mediaDao.findById(media.bookId) + + assertThat(created.extension).isNotNull + assertThat(created.extension).isInstanceOf(ProxyExtension::class.java) + assertThat((created.extension as ProxyExtension).extensionClassName).isEqualTo(MediaExtensionEpub::class.qualifiedName) + + val extension = mediaDao.findExtensionByIdOrNull(media.bookId) + assertThat(extension).isNotNull + assertThat(extension).isInstanceOf(MediaExtensionEpub::class.java) + assertThat(extension).isEqualTo(media.extension) + } + + @Test + fun `given existing media with extension when updating then it is persisted`() { + val media = Media( + status = Media.Status.READY, + mediaType = "application/epub+zip", + extension = MediaExtensionEpub( + landmarks = listOf(EpubTocEntry("title2", "href2", listOf(EpubTocEntry("subtitle2", "subhref2")))), + ), + bookId = book.id, + ) + mediaDao.insert(media) + + val updated = with(mediaDao.findById(media.bookId)) { + copy( + extension = MediaExtensionEpub( + toc = listOf(EpubTocEntry("title", "href", listOf(EpubTocEntry("subtitle", "subhref")))), + ), + ) + } + + mediaDao.update(updated) + val modified = mediaDao.findById(updated.bookId) + + assertThat(modified.bookId).isEqualTo(updated.bookId) + assertThat(modified.createdDate).isEqualTo(updated.createdDate) + assertThat(modified.lastModifiedDate).isNotEqualTo(updated.lastModifiedDate) + assertThat(modified.extension).isNotNull + assertThat(modified.extension).isInstanceOf(ProxyExtension::class.java) + assertThat((modified.extension as ProxyExtension).extensionClassName).isEqualTo(MediaExtensionEpub::class.qualifiedName) + + assertThat(mediaDao.findExtensionByIdOrNull(media.bookId)).isEqualTo(updated.extension) + } + + @Test + fun `given existing media with proxy extension when updating then it is kept as-is`() { + val media = Media( + status = Media.Status.READY, + mediaType = "application/epub+zip", + extension = MediaExtensionEpub( + landmarks = listOf(EpubTocEntry("title2", "href2", listOf(EpubTocEntry("subtitle2", "subhref2")))), + ), + bookId = book.id, + ) + mediaDao.insert(media) + + val updated = mediaDao.findById(media.bookId).copy(comment = "updated") + + mediaDao.update(updated) + val modified = mediaDao.findById(updated.bookId) + + assertThat(modified.bookId).isEqualTo(updated.bookId) + assertThat(modified.createdDate).isEqualTo(updated.createdDate) + assertThat(modified.lastModifiedDate).isNotEqualTo(updated.lastModifiedDate) + assertThat(modified.comment).isEqualTo(updated.comment) + assertThat(modified.extension).isNotNull + assertThat(modified.extension).isInstanceOf(ProxyExtension::class.java) + assertThat((modified.extension as ProxyExtension).extensionClassName).isEqualTo(MediaExtensionEpub::class.qualifiedName) + + val extension = mediaDao.findExtensionByIdOrNull(media.bookId) + assertThat(extension).isNotNull + assertThat(extension).isInstanceOf(MediaExtensionEpub::class.java) + assertThat(extension).isEqualTo(media.extension) + } + } + @Nested inner class MissingPageHash {