mirror of
https://github.com/gotson/komga.git
synced 2026-05-09 05:10:19 +02:00
feat(api): add Readium Progression API
This commit is contained in:
parent
fbc103467e
commit
20799ad196
13 changed files with 372 additions and 18 deletions
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package org.gotson.komga.domain.model
|
||||
|
||||
data class R2Device(
|
||||
val id: String,
|
||||
val name: String,
|
||||
)
|
||||
|
|
@ -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("", ""),
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}" }
|
||||
|
|
|
|||
|
|
@ -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<String>.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 <reified T> 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -480,5 +480,7 @@ class BookDtoDao(
|
|||
readDate = readDate,
|
||||
created = createdDate,
|
||||
lastModified = lastModifiedDate,
|
||||
deviceId = deviceId,
|
||||
deviceName = deviceName,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<R2Locator>(locator),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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<R2Progression> =
|
||||
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],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<R2Locator> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue