From 20799ad1964d84a88a535124087506057822a34e Mon Sep 17 00:00:00 2001 From: Gauthier Roebroeck Date: Thu, 7 Dec 2023 15:34:37 +0800 Subject: [PATCH] feat(api): add Readium Progression API --- .../sqlite/V20231206152158__progression.sql | 6 + .../org/gotson/komga/domain/model/R2Device.kt | 6 + .../komga/domain/model/R2Progression.kt | 16 ++ .../gotson/komga/domain/model/ReadProgress.kt | 3 + .../komga/domain/service/BookLifecycle.kt | 66 +++++++ .../gotson/komga/infrastructure/jooq/Utils.kt | 29 ++- .../infrastructure/jooq/main/BookDtoDao.kt | 2 + .../infrastructure/jooq/main/MediaDao.kt | 18 +- .../jooq/main/ReadProgressDao.kt | 17 ++ .../komga/interfaces/api/dto/Constants.kt | 2 + .../interfaces/api/rest/BookController.kt | 41 ++++ .../komga/interfaces/api/rest/dto/BookDto.kt | 2 + .../komga/domain/service/BookLifecycleTest.kt | 182 ++++++++++++++++++ 13 files changed, 372 insertions(+), 18 deletions(-) create mode 100644 komga/src/flyway/resources/db/migration/sqlite/V20231206152158__progression.sql create mode 100644 komga/src/main/kotlin/org/gotson/komga/domain/model/R2Device.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/domain/model/R2Progression.kt diff --git a/komga/src/flyway/resources/db/migration/sqlite/V20231206152158__progression.sql b/komga/src/flyway/resources/db/migration/sqlite/V20231206152158__progression.sql new file mode 100644 index 00000000..53a20696 --- /dev/null +++ b/komga/src/flyway/resources/db/migration/sqlite/V20231206152158__progression.sql @@ -0,0 +1,6 @@ +ALTER TABLE READ_PROGRESS + ADD COLUMN device_id varchar default ''; +ALTER TABLE READ_PROGRESS + ADD COLUMN device_name varchar default ''; +ALTER TABLE READ_PROGRESS + ADD COLUMN locator blob null; diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/R2Device.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/R2Device.kt new file mode 100644 index 00000000..544315cb --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/R2Device.kt @@ -0,0 +1,6 @@ +package org.gotson.komga.domain.model + +data class R2Device( + val id: String, + val name: String, +) diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/R2Progression.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/R2Progression.kt new file mode 100644 index 00000000..c6141377 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/R2Progression.kt @@ -0,0 +1,16 @@ +package org.gotson.komga.domain.model + +import org.gotson.komga.language.toZonedDateTime +import java.time.ZonedDateTime + +data class R2Progression( + val modified: ZonedDateTime, + val device: R2Device, + val locator: R2Locator, +) + +fun ReadProgress.toR2Progression() = R2Progression( + modified = readDate.toZonedDateTime(), + device = R2Device(deviceId, deviceName), + locator = locator ?: R2Locator("", ""), +) diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/ReadProgress.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/ReadProgress.kt index e67a6a0f..62c024ec 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/ReadProgress.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/ReadProgress.kt @@ -8,6 +8,9 @@ data class ReadProgress( val page: Int, val completed: Boolean, val readDate: LocalDateTime = LocalDateTime.now(), + val deviceId: String = "", + val deviceName: String = "", + val locator: R2Locator? = null, override val createdDate: LocalDateTime = LocalDateTime.now(), override val lastModifiedDate: LocalDateTime = createdDate, diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt index bb3088d6..a0943bfe 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt @@ -11,8 +11,10 @@ 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.R2Progression import org.gotson.komga.domain.model.ReadProgress import org.gotson.komga.domain.model.ThumbnailBook import org.gotson.komga.domain.model.TypedBytes @@ -33,6 +35,7 @@ import org.springframework.context.ApplicationEventPublisher import org.springframework.data.domain.Sort import org.springframework.stereotype.Service import org.springframework.transaction.support.TransactionTemplate +import org.springframework.web.util.UriUtils import java.io.File import java.time.LocalDateTime import kotlin.io.path.deleteIfExists @@ -41,6 +44,7 @@ import kotlin.io.path.isWritable import kotlin.io.path.listDirectoryEntries import kotlin.io.path.notExists import kotlin.io.path.toPath +import kotlin.math.roundToInt private val logger = KotlinLogging.logger {} @@ -376,6 +380,68 @@ class BookLifecycle( } } + fun markProgression(book: Book, user: KomgaUser, newProgression: R2Progression) { + readProgressRepository.findByBookIdAndUserIdOrNull(book.id, user.id)?.let { savedProgress -> + check(newProgression.modified.toLocalDateTime().isAfter(savedProgress.readDate)) { "Progression is older than existing" } + } + + val media = mediaRepository.findById(book.id) + requireNotNull(media.profile) { "Media has no profile" } + val progress = when (media.profile!!) { + MediaProfile.DIVINA, + MediaProfile.PDF, + -> { + require(newProgression.locator.locations?.position in 1..media.pageCount) { "Page argument (${newProgression.locator.locations?.position}) must be within 1 and book page count (${media.pageCount})" } + ReadProgress( + book.id, + user.id, + newProgression.locator.locations!!.position!!, + newProgression.locator.locations.position == media.pageCount, + newProgression.modified.toLocalDateTime(), + newProgression.device.id, + newProgression.device.name, + newProgression.locator, + ) + } + + MediaProfile.EPUB -> { + val href = newProgression.locator.href.replaceBefore("/resource/", "").removePrefix("/resource/").let { UriUtils.decode(it, Charsets.UTF_8) } + require(href in media.files.map { it.fileName }) { "Resource does not exist in book: $href" } + requireNotNull(newProgression.locator.locations?.progression) { "location.progression is required" } + + val extension = mediaRepository.findExtensionByIdOrNull(book.id) as? MediaExtensionEpub + ?: throw IllegalArgumentException("Epub extension not found") + // match progression with positions + val matchingPositions = extension.positions.filter { it.href == href } + val matchedPosition = + matchingPositions.firstOrNull { it.locations!!.progression == newProgression.locator.locations!!.progression } + ?: run { + // no exact match + val before = matchingPositions.filter { it.locations!!.progression!! < newProgression.locator.locations!!.progression!! }.maxByOrNull { it.locations!!.position!! } + val after = matchingPositions.filter { it.locations!!.progression!! > newProgression.locator.locations!!.progression!! }.minByOrNull { it.locations!!.position!! } + if (before == null || after == null || before.locations!!.position!! > after.locations!!.position!!) + throw IllegalArgumentException("Invalid progression") + before + } + + val totalProgression = matchedPosition.locations?.totalProgression + ReadProgress( + book.id, + user.id, + totalProgression?.let { (media.pageCount * it).roundToInt() } ?: 0, + totalProgression?.let { it >= 0.99F } ?: false, + newProgression.modified.toLocalDateTime(), + newProgression.device.id, + newProgression.device.name, + newProgression.locator, + ) + } + } + + readProgressRepository.save(progress) + eventPublisher.publishEvent(DomainEvent.ReadProgressChanged(progress)) + } + fun deleteBookFiles(book: Book) { if (book.path.notExists()) return logger.info { "Cannot delete book file, path does not exist: ${book.path}" } if (!book.path.isWritable()) return logger.info { "Cannot delete book file, path is not writable: ${book.path}" } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/Utils.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/Utils.kt index 49be785c..c958ea71 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/Utils.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/Utils.kt @@ -1,5 +1,6 @@ package org.gotson.komga.infrastructure.jooq +import com.fasterxml.jackson.databind.ObjectMapper import org.gotson.komga.domain.model.AllowExclude import org.gotson.komga.domain.model.ContentRestrictions import org.gotson.komga.infrastructure.datasource.SqliteUdfDataSource @@ -10,8 +11,9 @@ import org.jooq.Field import org.jooq.SortField import org.jooq.impl.DSL import org.springframework.data.domain.Sort -import java.time.LocalDateTime -import java.time.ZoneId +import java.io.ByteArrayOutputStream +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream fun Field.noCase() = this.collate("NOCASE") @@ -90,3 +92,26 @@ fun ContentRestrictions.toCondition(dsl: DSLContext): Condition { return ageAllowed.or(labelAllowed) .and(ageDenied.and(labelDenied)) } + +fun ObjectMapper.serializeJsonGz(obj: Any): ByteArray? = + try { + ByteArrayOutputStream().use { baos -> + GZIPOutputStream(baos).use { gz -> + this.writeValue(gz, obj) + baos.toByteArray() + } + } + } catch (e: Exception) { + null + } + +inline fun ObjectMapper.deserializeJsonGz(gzJson: ByteArray?): T? { + if (gzJson == null) return null + return try { + GZIPInputStream(gzJson.inputStream()).use { gz -> + this.readValue(gz, T::class.java) as T + } + } catch (e: Exception) { + null + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDtoDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDtoDao.kt index 47070251..51cb4ed5 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDtoDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDtoDao.kt @@ -480,5 +480,7 @@ class BookDtoDao( readDate = readDate, created = createdDate, lastModified = lastModifiedDate, + deviceId = deviceId, + deviceName = deviceName, ) } 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 cd3b4b4e..b1db6bd5 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 @@ -22,11 +22,9 @@ 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 {} @@ -147,7 +145,7 @@ class MediaDao( media.comment, media.pageCount, media.extension?.let { if (it is ProxyExtension) null else it::class.qualifiedName }, - media.extension?.let { if (it is ProxyExtension) null else serializeExtension(it) }, + media.extension?.let { if (it is ProxyExtension) null else mapper.serializeJsonGz(it) }, ) } }.execute() @@ -232,7 +230,7 @@ class MediaDao( .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.EXTENSION_VALUE_BLOB, mapper.serializeJsonGz(media.extension)) } } .set(m.LAST_MODIFIED_DATE, LocalDateTime.now(ZoneId.of("Z"))) @@ -282,18 +280,6 @@ class MediaDao( createdDate = createdDate.toCurrentTimeZone(), lastModifiedDate = lastModifiedDate.toCurrentTimeZone(), ) - 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 serialize media extension" } - null - } fun deserializeExtension(extensionClass: String?, extensionBlob: ByteArray?): MediaExtension? { if (extensionClass == null || extensionBlob == null) return null diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ReadProgressDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ReadProgressDao.kt index ea3a2ba2..817ea8df 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ReadProgressDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ReadProgressDao.kt @@ -1,9 +1,13 @@ package org.gotson.komga.infrastructure.jooq.main +import com.fasterxml.jackson.databind.ObjectMapper +import org.gotson.komga.domain.model.R2Locator import org.gotson.komga.domain.model.ReadProgress import org.gotson.komga.domain.persistence.ReadProgressRepository +import org.gotson.komga.infrastructure.jooq.deserializeJsonGz import org.gotson.komga.infrastructure.jooq.insertTempStrings import org.gotson.komga.infrastructure.jooq.selectTempStrings +import org.gotson.komga.infrastructure.jooq.serializeJsonGz import org.gotson.komga.jooq.main.Tables import org.gotson.komga.jooq.main.tables.records.ReadProgressRecord import org.gotson.komga.language.toCurrentTimeZone @@ -21,6 +25,7 @@ import java.time.ZoneId class ReadProgressDao( private val dsl: DSLContext, @Value("#{@komgaProperties.database.batchChunkSize}") private val batchSize: Int, + private val mapper: ObjectMapper, ) : ReadProgressRepository { private val r = Tables.READ_PROGRESS @@ -84,6 +89,9 @@ class ReadProgressDao( r.PAGE, r.COMPLETED, r.READ_DATE, + r.DEVICE_ID, + r.DEVICE_NAME, + r.LOCATOR, ) .values( bookId, @@ -91,12 +99,18 @@ class ReadProgressDao( page, completed, readDate.toUTC(), + deviceId, + deviceName, + locator?.let { mapper.serializeJsonGz(it) }, ) .onDuplicateKeyUpdate() .set(r.PAGE, page) .set(r.COMPLETED, completed) .set(r.READ_DATE, readDate.toUTC()) .set(r.LAST_MODIFIED_DATE, LocalDateTime.now(ZoneId.of("Z"))) + .set(r.DEVICE_ID, deviceId) + .set(r.DEVICE_NAME, deviceName) + .set(r.LOCATOR, locator?.let { mapper.serializeJsonGz(it) }) @Transactional override fun delete(bookId: String, userId: String) { @@ -179,5 +193,8 @@ class ReadProgressDao( readDate = readDate.toCurrentTimeZone(), createdDate = createdDate.toCurrentTimeZone(), lastModifiedDate = lastModifiedDate.toCurrentTimeZone(), + deviceId = deviceId, + deviceName = deviceName, + locator = mapper.deserializeJsonGz(locator), ) } 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 be012769..34fb71ac 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 @@ -6,6 +6,7 @@ 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 MEDIATYPE_PROGRESSION_JSON_VALUE = "application/vnd.readium.progression+json" const val PROFILE_DIVINA = "https://readium.org/webpub-manifest/profiles/divina" const val PROFILE_EPUB = "https://readium.org/webpub-manifest/profiles/epub" @@ -15,3 +16,4 @@ val MEDIATYPE_OPDS_PUBLICATION_JSON = MediaType("application", "opds-publication 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") +val MEDIATYPE_PROGRESSION_JSON = MediaType("application", "vnd.readium.progression+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 3c417c74..274c6eff 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 @@ -27,15 +27,18 @@ 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 +import org.gotson.komga.domain.model.R2Progression import org.gotson.komga.domain.model.ROLE_ADMIN import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD import org.gotson.komga.domain.model.ROLE_PAGE_STREAMING import org.gotson.komga.domain.model.ReadStatus import org.gotson.komga.domain.model.ThumbnailBook +import org.gotson.komga.domain.model.toR2Progression import org.gotson.komga.domain.persistence.BookMetadataRepository import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.MediaRepository import org.gotson.komga.domain.persistence.ReadListRepository +import org.gotson.komga.domain.persistence.ReadProgressRepository import org.gotson.komga.domain.persistence.SeriesMetadataRepository import org.gotson.komga.domain.persistence.ThumbnailBookRepository import org.gotson.komga.domain.service.BookAnalyzer @@ -54,6 +57,7 @@ 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_PROGRESSION_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 @@ -119,6 +123,7 @@ class BookController( private val bookAnalyzer: BookAnalyzer, private val bookLifecycle: BookLifecycle, private val bookRepository: BookRepository, + private val readProgressRepository: ReadProgressRepository, private val bookMetadataRepository: BookMetadataRepository, private val seriesMetadataRepository: SeriesMetadataRepository, private val mediaRepository: MediaRepository, @@ -741,6 +746,42 @@ class BookController( .body(R2Positions(extension.positions.size, extension.positions)) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + @GetMapping( + value = ["api/v1/books/{bookId}/progression"], + produces = [MEDIATYPE_PROGRESSION_JSON_VALUE], + ) + fun getProgression( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + ): ResponseEntity = + bookRepository.findByIdOrNull(bookId)?.let { book -> + principal.user.checkContentRestriction(book) + + readProgressRepository.findByBookIdAndUserIdOrNull(bookId, principal.user.id)?.let { + ResponseEntity.ok(it.toR2Progression()) + } ?: ResponseEntity.noContent().build() + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @PutMapping("api/v1/books/{bookId}/progression") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun markProgression( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + @RequestBody progression: R2Progression, + ) { + bookRepository.findByIdOrNull(bookId)?.let { book -> + principal.user.checkContentRestriction(book) + + try { + bookLifecycle.markProgression(book, principal.user, progression) + } catch (e: IllegalStateException) { + throw ResponseStatusException(HttpStatus.CONFLICT, e.message) + } catch (e: IllegalArgumentException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } + } ?: 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/BookDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/BookDto.kt index 5607b217..806e7b3b 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/BookDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/BookDto.kt @@ -79,4 +79,6 @@ data class ReadProgressDto( val created: LocalDateTime, @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") val lastModified: LocalDateTime, + val deviceId: String, + val deviceName: String, ) diff --git a/komga/src/test/kotlin/org/gotson/komga/domain/service/BookLifecycleTest.kt b/komga/src/test/kotlin/org/gotson/komga/domain/service/BookLifecycleTest.kt index 8815fd36..586d910b 100644 --- a/komga/src/test/kotlin/org/gotson/komga/domain/service/BookLifecycleTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/domain/service/BookLifecycleTest.kt @@ -7,10 +7,17 @@ import com.ninjasquad.springmockk.SpykBean import io.mockk.every import io.mockk.verify import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatNoException +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.KomgaUser 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.R2Device +import org.gotson.komga.domain.model.R2Locator +import org.gotson.komga.domain.model.R2Progression import org.gotson.komga.domain.model.ThumbnailBook import org.gotson.komga.domain.model.makeBook import org.gotson.komga.domain.model.makeBookPage @@ -26,11 +33,16 @@ import org.gotson.komga.domain.persistence.ThumbnailBookRepository import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import java.nio.file.Files import java.nio.file.Paths +import java.time.ZonedDateTime @SpringBootTest class BookLifecycleTest( @@ -277,4 +289,174 @@ class BookLifecycleTest( assertThat(Files.notExists(bookPath)) } } + + @Nested + inner class Progression { + @BeforeEach + fun setup() { + makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + } + + private val device = R2Device("abc", "device") + private val epubResources = listOf( + MediaFile("ch1.xhtml", "application/xhtml+xml"), + MediaFile("ch2.xhtml", "application/xhtml+xml"), + MediaFile("ch3.xhtml", "application/xhtml+xml"), + ) + + private fun makeEpubPositions(): List { + var startPosition = 1 + return epubResources.flatMap { file -> + (1..10).map { + R2Locator(file.fileName, file.mediaType!!, locations = R2Locator.Location(position = startPosition++, progression = it.toFloat())) + } + } + } + + @Test + fun `given book when marking progress older than saved then it fails`() { + val book = bookRepository.findAll().first() + mediaRepository.findById(book.id).let { media -> + mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = "application/zip", pageCount = 10)) + } + + val progress = R2Progression(ZonedDateTime.now(), device, R2Locator("", "", locations = R2Locator.Location(position = 5))) + + bookLifecycle.markProgression(book, user1, progress) + + // when + val thrown = catchThrowable { + bookLifecycle.markProgression(book, user1, progress.copy(modified = progress.modified.minusHours(1))) + } + + // then + assertThat(thrown) + .isInstanceOf(IllegalStateException::class.java) + .hasMessageContaining("older than existing") + } + + @ParameterizedTest + @ValueSource(strings = ["application/zip", "application/pdf"]) + fun `given divina or pdf book when marking progress over the page count then it fails`(mediaType: String) { + val book = bookRepository.findAll().first() + mediaRepository.findById(book.id).let { media -> + mediaRepository.update(media.copy(status = Media.Status.READY, pageCount = 10, mediaType = mediaType)) + } + + // when + val thrown = catchThrowable { + bookLifecycle.markProgression(book, user1, R2Progression(ZonedDateTime.now(), device, R2Locator("", "", locations = R2Locator.Location(position = 15)))) + } + + // then + assertThat(thrown) + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("must be within 1 and book page count") + } + + @Test + fun `given epub book when marking progress with non-existing href then it fails`() { + val book = bookRepository.findAll().first() + mediaRepository.findById(book.id).let { media -> + mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = "application/epub+zip", files = epubResources)) + } + + // when + val thrown = catchThrowable { + bookLifecycle.markProgression(book, user1, R2Progression(ZonedDateTime.now(), device, R2Locator("ch5.xhtml", "", locations = R2Locator.Location(position = 15)))) + } + + // then + assertThat(thrown) + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("Resource does not exist in book") + } + + @Test + fun `given epub book when marking progress without location progression then it fails`() { + val book = bookRepository.findAll().first() + mediaRepository.findById(book.id).let { media -> + mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = "application/epub+zip", files = epubResources)) + } + + // when + val thrown = catchThrowable { + bookLifecycle.markProgression(book, user1, R2Progression(ZonedDateTime.now(), device, R2Locator("ch1.xhtml", "", locations = R2Locator.Location(position = 15)))) + } + + // then + assertThat(thrown) + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("location.progression is required") + } + + @Test + fun `given epub book without extension when marking progress then it fails`() { + val book = bookRepository.findAll().first() + mediaRepository.findById(book.id).let { media -> + mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = "application/epub+zip", files = epubResources)) + } + + // when + val thrown = catchThrowable { + bookLifecycle.markProgression(book, user1, R2Progression(ZonedDateTime.now(), device, R2Locator("ch1.xhtml", "", locations = R2Locator.Location(progression = 0.3F)))) + } + + // then + assertThat(thrown) + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("extension not found") + } + + @Test + fun `given epub book when marking progress with exact position then it succeeds`() { + val book = bookRepository.findAll().first() + val epubPositions = makeEpubPositions() + mediaRepository.findById(book.id).let { media -> + mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = "application/epub+zip", files = epubResources, extension = MediaExtensionEpub(positions = epubPositions))) + } + + // when + val newProgression = R2Progression(ZonedDateTime.now(), device, epubPositions.first()) + assertThatNoException().isThrownBy { + bookLifecycle.markProgression(book, user1, newProgression) + } + val savedProgression = readProgressRepository.findByBookIdAndUserIdOrNull(book.id, user1.id) + + // then + assertThat(savedProgression).isNotNull + assertThat(savedProgression!!.locator).isEqualTo(newProgression.locator) + } + + @Test + fun `given epub book when marking progress with skewed position then it succeeds`() { + val book = bookRepository.findAll().first() + val epubPositions = makeEpubPositions() + mediaRepository.findById(book.id).let { media -> + mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = "application/epub+zip", files = epubResources, extension = MediaExtensionEpub(positions = epubPositions))) + } + + // when + val newProgression = R2Progression( + ZonedDateTime.now(), + device, + with(epubPositions[0]) { + copy(locations = locations!!.copy(progression = locations!!.progression!! + 0.5F)) + }, + ) + assertThatNoException().isThrownBy { + bookLifecycle.markProgression(book, user1, newProgression) + } + val savedProgression = readProgressRepository.findByBookIdAndUserIdOrNull(book.id, user1.id) + + // then + assertThat(savedProgression).isNotNull + assertThat(savedProgression!!.locator).isEqualTo(newProgression.locator) + } + } }