From 64ce165cccb9626014e0137848bb1b77082cec25 Mon Sep 17 00:00:00 2001 From: Gauthier Roebroeck Date: Tue, 20 Aug 2024 15:39:03 +0800 Subject: [PATCH] kobo sync read progress --- .../komga/infrastructure/kobo/KoboHeaders.kt | 1 + .../interfaces/api/kobo/KoboController.kt | 167 +++++++++++++++++- .../interfaces/api/kobo/dto/BookmarkDto.kt | 8 + .../interfaces/api/kobo/dto/LocationDto.kt | 13 +- .../api/kobo/dto/ReadingStateDto.kt | 32 ++++ .../kobo/dto/ReadingStateUpdateResultDto.kt | 25 +++ .../interfaces/api/kobo/dto/ResultDto.kt | 16 ++ .../interfaces/api/kobo/dto/StatusInfoDto.kt | 1 + 8 files changed, 260 insertions(+), 3 deletions(-) create mode 100644 komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ReadingStateUpdateResultDto.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ResultDto.kt diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/kobo/KoboHeaders.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/kobo/KoboHeaders.kt index f99d1f4dc1c..675d0aaa3b9 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/kobo/KoboHeaders.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/kobo/KoboHeaders.kt @@ -4,4 +4,5 @@ object KoboHeaders { const val X_KOBO_SYNCTOKEN = "x-kobo-synctoken" const val X_KOBO_USERKEY = "X-Kobo-userkey" const val X_KOBO_SYNC = "X-Kobo-sync" + const val X_KOBO_DEVICEID = "X-Kobo-deviceid" } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/KoboController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/KoboController.kt index 7ef4bed0057..1287cf85f6a 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/KoboController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/KoboController.kt @@ -6,14 +6,20 @@ import com.fasterxml.jackson.databind.node.ObjectNode import com.fasterxml.jackson.module.kotlin.treeToValue import io.github.oshai.kotlinlogging.KotlinLogging import org.apache.commons.lang3.RandomStringUtils +import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.KomgaSyncToken +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.ROLE_FILE_DOWNLOAD import org.gotson.komga.domain.model.SyncPoint import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.ReadProgressRepository import org.gotson.komga.domain.persistence.SyncPointRepository import org.gotson.komga.domain.service.BookLifecycle import org.gotson.komga.domain.service.SyncPointLifecycle import org.gotson.komga.infrastructure.configuration.KomgaProperties +import org.gotson.komga.infrastructure.kobo.KoboHeaders.X_KOBO_DEVICEID import org.gotson.komga.infrastructure.kobo.KoboHeaders.X_KOBO_SYNC import org.gotson.komga.infrastructure.kobo.KoboHeaders.X_KOBO_SYNCTOKEN import org.gotson.komga.infrastructure.kobo.KoboHeaders.X_KOBO_USERKEY @@ -24,14 +30,24 @@ import org.gotson.komga.infrastructure.web.getCurrentRequest import org.gotson.komga.interfaces.api.CommonBookController import org.gotson.komga.interfaces.api.kobo.dto.AuthDto import org.gotson.komga.interfaces.api.kobo.dto.BookEntitlementContainerDto +import org.gotson.komga.interfaces.api.kobo.dto.BookmarkDto import org.gotson.komga.interfaces.api.kobo.dto.ChangedEntitlementDto import org.gotson.komga.interfaces.api.kobo.dto.KoboBookMetadataDto import org.gotson.komga.interfaces.api.kobo.dto.NewEntitlementDto +import org.gotson.komga.interfaces.api.kobo.dto.ReadingStateDto +import org.gotson.komga.interfaces.api.kobo.dto.ReadingStateUpdateResultDto +import org.gotson.komga.interfaces.api.kobo.dto.RequestResultDto import org.gotson.komga.interfaces.api.kobo.dto.ResourcesDto +import org.gotson.komga.interfaces.api.kobo.dto.ResultDto +import org.gotson.komga.interfaces.api.kobo.dto.StatisticsDto +import org.gotson.komga.interfaces.api.kobo.dto.StatusDto +import org.gotson.komga.interfaces.api.kobo.dto.StatusInfoDto import org.gotson.komga.interfaces.api.kobo.dto.SyncResultDto import org.gotson.komga.interfaces.api.kobo.dto.TestsDto import org.gotson.komga.interfaces.api.kobo.dto.toBookEntitlementDto +import org.gotson.komga.interfaces.api.kobo.dto.toDto import org.gotson.komga.interfaces.api.kobo.persistence.KoboDtoRepository +import org.gotson.komga.language.toUTCZoned import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.http.HttpStatus @@ -42,6 +58,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestHeader import org.springframework.web.bind.annotation.RequestMapping @@ -52,6 +69,7 @@ import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBo import org.springframework.web.servlet.support.ServletUriComponentsBuilder import org.springframework.web.util.UriBuilder import org.springframework.web.util.UriComponentsBuilder +import java.time.ZonedDateTime import java.util.UUID private val logger = KotlinLogging.logger {} @@ -116,6 +134,7 @@ class KoboController( private val commonBookController: CommonBookController, private val bookLifecycle: BookLifecycle, private val bookRepository: BookRepository, + private val readProgressRepository: ReadProgressRepository, ) { @GetMapping("ping") fun ping() = "pong" @@ -143,6 +162,9 @@ class KoboController( .body(ResourcesDto(resources)) } + /** + * @return an [AuthDto] + */ @PostMapping("v1/auth/device") fun authDevice( @RequestBody body: JsonNode, @@ -173,6 +195,9 @@ class KoboController( testKey = userKey ?: "", ) + /** + * @return an array of [SyncResultDto] + */ @GetMapping("v1/library/sync") fun syncLibrary( @AuthenticationPrincipal principal: KomgaPrincipal, @@ -222,7 +247,8 @@ class KoboController( logger.debug { "Library sync: ${booksAdded.numberOfElements} books added, ${booksChanged.numberOfElements} books changed, ${booksRemoved.numberOfElements} books removed" } - val metadata = koboDtoRepository.findBookMetadataByIds((booksAdded.content + booksChanged.content + booksRemoved.content).map { it.bookId }, getDownloadUrlBuilder(authToken)).associateBy { it.entitlementId } + val metadata = koboDtoRepository.findBookMetadataByIds((booksAdded.content + booksChanged.content).map { it.bookId }, getDownloadUrlBuilder(authToken)).associateBy { it.entitlementId } + val readProgress = readProgressRepository.findAllByBookIdsAndUserId((booksAdded.content + booksChanged.content).map { it.bookId }, principal.user.id).associateBy { it.bookId } buildList { addAll( @@ -231,6 +257,7 @@ class KoboController( BookEntitlementContainerDto( bookEntitlement = it.toBookEntitlementDto(false), bookMetadata = metadata[it.bookId]!!, + readingState = readProgress[it.bookId]?.toDto() ?: getEmptyReadProgressForBook(it.bookId, it.createdDate), ), ) }, @@ -241,6 +268,7 @@ class KoboController( BookEntitlementContainerDto( bookEntitlement = it.toBookEntitlementDto(false), bookMetadata = metadata[it.bookId]!!, + readingState = readProgress[it.bookId]?.toDto() ?: getEmptyReadProgressForBook(it.bookId, it.createdDate), ), ) }, @@ -264,12 +292,14 @@ class KoboController( logger.debug { "Library sync: ${books.numberOfElements} books" } val metadata = koboDtoRepository.findBookMetadataByIds(books.content.map { it.bookId }, getDownloadUrlBuilder(authToken)).associateBy { it.entitlementId } + val readProgress = readProgressRepository.findAllByBookIdsAndUserId(books.content.map { it.bookId }, principal.user.id).associateBy { it.bookId } books.content.map { NewEntitlementDto( BookEntitlementContainerDto( bookEntitlement = it.toBookEntitlementDto(false), bookMetadata = metadata[it.bookId]!!, + readingState = readProgress[it.bookId]?.toDto() ?: getEmptyReadProgressForBook(it.bookId, it.createdDate), ), ) } @@ -313,6 +343,9 @@ class KoboController( .body(syncResultMerged) } + /** + * @return an array of [KoboBookMetadataDto] + */ @GetMapping("/v1/library/{bookId}/metadata") fun getBookMetadata( @PathVariable authToken: String, @@ -323,6 +356,100 @@ class KoboController( else ResponseEntity.ok(koboDtoRepository.findBookMetadataByIds(listOf(bookId), getDownloadUrlBuilder(authToken))) + /** + * @return an array of [ReadingStateDto] + */ + @GetMapping("/v1/library/{bookId}/state") + fun getState( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + ): ResponseEntity<*> { + val book = + bookRepository.findByIdOrNull(bookId) + ?: if (koboProxy.isEnabled()) + return koboProxy.proxyCurrentRequest() + else + throw ResponseStatusException(HttpStatus.NOT_FOUND) + + val response = readProgressRepository.findByBookIdAndUserIdOrNull(bookId, principal.user.id)?.toDto() ?: getEmptyReadProgressForBook(book) + return ResponseEntity.ok(listOf(response)) + } + + /** + * @return a [RequestResultDto] + */ + @PutMapping("/v1/library/{bookId}/state") + fun updateState( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + @RequestBody koboUpdate: ReadingStateDto, + @RequestHeader(name = X_KOBO_DEVICEID, required = false) koboDeviceId: String = "unknown", + ): ResponseEntity<*> { + val book = + bookRepository.findByIdOrNull(bookId) + ?: if (koboProxy.isEnabled()) + return koboProxy.proxyCurrentRequest(koboUpdate) + else + throw ResponseStatusException(HttpStatus.NOT_FOUND) + + if (koboUpdate.currentBookmark.location == null) throw ResponseStatusException(HttpStatus.BAD_REQUEST) + + // convert the Kobo update request to an R2Progression + val r2Progression = + R2Progression( + modified = koboUpdate.lastModified, + device = + R2Device( + id = koboDeviceId, + // TODO: get API key comment + name = "need to get the API key comment", + ), + locator = + R2Locator( + href = koboUpdate.currentBookmark.location.source, + // assume default + type = "application/xhtml+xml", + locations = + R2Locator.Location( + progression = koboUpdate.currentBookmark.progressPercent, + ), + ), + ) + + val response = + try { + bookLifecycle.markProgression(book, principal.user, r2Progression) + + RequestResultDto( + requestResult = ResultDto.SUCCESS, + updateResults = + listOf( + ReadingStateUpdateResultDto( + entitlementId = bookId, + currentBookmarkResult = ResultDto.SUCCESS.wrapped(), + statisticsResult = ResultDto.IGNORED.wrapped(), + statusInfoResult = ResultDto.SUCCESS.wrapped(), + ), + ), + ) + } catch (e: Exception) { + RequestResultDto( + requestResult = ResultDto.FAILURE, + updateResults = + listOf( + ReadingStateUpdateResultDto( + entitlementId = bookId, + currentBookmarkResult = ResultDto.FAILURE.wrapped(), + statisticsResult = ResultDto.FAILURE.wrapped(), + statusInfoResult = ResultDto.FAILURE.wrapped(), + ), + ), + ) + } + + return ResponseEntity.ok(response) + } + @GetMapping( value = ["v1/books/{bookId}/file/epub"], produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE], @@ -396,4 +523,42 @@ class KoboController( workId = bookId, title = bookId, ) + + private fun getEmptyReadProgressForBook(book: Book): ReadingStateDto { + val createdDateUTC = book.createdDate.toUTCZoned() + return ReadingStateDto( + created = createdDateUTC, + lastModified = createdDateUTC, + priorityTimestamp = createdDateUTC, + entitlementId = book.id, + currentBookmark = BookmarkDto(createdDateUTC), + statistics = StatisticsDto(createdDateUTC), + statusInfo = + StatusInfoDto( + lastModified = createdDateUTC, + status = StatusDto.READY_TO_READ, + timesStartedReading = 0, + ), + ) + } + + private fun getEmptyReadProgressForBook( + bookId: String, + createdDate: ZonedDateTime, + ): ReadingStateDto { + return ReadingStateDto( + created = createdDate, + lastModified = createdDate, + priorityTimestamp = createdDate, + entitlementId = bookId, + currentBookmark = BookmarkDto(createdDate), + statistics = StatisticsDto(createdDate), + statusInfo = + StatusInfoDto( + lastModified = createdDate, + status = StatusDto.READY_TO_READ, + timesStartedReading = 0, + ), + ) + } } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/BookmarkDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/BookmarkDto.kt index c481f0d5c44..ffba4abd6bd 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/BookmarkDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/BookmarkDto.kt @@ -9,7 +9,15 @@ import java.time.ZonedDateTime @JsonInclude(JsonInclude.Include.NON_NULL) data class BookmarkDto( val lastModified: ZonedDateTime, + /** + * Total progression in the book. + * Between 0 and 100. + */ val progressPercent: Float? = null, + /** + * Progression within the resource. + * Between 0 and 100. + */ val contentSourceProgressPercent: Float? = null, val location: LocationDto? = null, ) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/LocationDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/LocationDto.kt index 3c64747b90d..0beb12ac6f7 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/LocationDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/LocationDto.kt @@ -5,7 +5,16 @@ import com.fasterxml.jackson.databind.annotation.JsonNaming @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) data class LocationDto( - val value: String, - val type: String, + /** + * For type=KoboSpan values are in the form "kobo.x.y" + */ + val value: String? = null, + /** + * Typically "KoboSpan" + */ + val type: String? = null, + /** + * The epub HTML resource + */ val source: String, ) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ReadingStateDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ReadingStateDto.kt index 1218c46e3df..f2f0dd5c2d0 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ReadingStateDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ReadingStateDto.kt @@ -2,6 +2,8 @@ package org.gotson.komga.interfaces.api.kobo.dto import com.fasterxml.jackson.databind.PropertyNamingStrategies import com.fasterxml.jackson.databind.annotation.JsonNaming +import org.gotson.komga.domain.model.ReadProgress +import org.gotson.komga.language.toUTCZoned import java.time.ZonedDateTime @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) @@ -17,3 +19,33 @@ data class ReadingStateDto( val statistics: StatisticsDto, val statusInfo: StatusInfoDto, ) + +fun ReadProgress.toDto() = + ReadingStateDto( + created = this.createdDate.toUTCZoned(), + lastModified = this.lastModifiedDate.toUTCZoned(), + priorityTimestamp = this.lastModifiedDate.toUTCZoned(), + entitlementId = this.bookId, + currentBookmark = + BookmarkDto( + lastModified = this.lastModifiedDate.toUTCZoned(), + progressPercent = this.locator?.locations?.totalProgression, + contentSourceProgressPercent = this.locator?.locations?.progression, + location = this.locator?.let { LocationDto(source = it.href) }, + ), + statistics = + StatisticsDto( + lastModified = this.lastModifiedDate.toUTCZoned(), + ), + statusInfo = + StatusInfoDto( + lastModified = this.lastModifiedDate.toUTCZoned(), + status = + when { + this.completed -> StatusDto.FINISHED + !this.completed -> StatusDto.READING + else -> StatusDto.READY_TO_READ + }, + timesStartedReading = 1, + ), + ) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ReadingStateUpdateResultDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ReadingStateUpdateResultDto.kt new file mode 100644 index 00000000000..5c257d7ea8d --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ReadingStateUpdateResultDto.kt @@ -0,0 +1,25 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class RequestResultDto( + val requestResult: ResultDto, + val updateResults: Collection, +) + +interface UpdateResultDto + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class ReadingStateUpdateResultDto( + val entitlementId: String, + val currentBookmarkResult: WrappedResultDto, + val statisticsResult: WrappedResultDto, + val statusInfoResult: WrappedResultDto, +) : UpdateResultDto + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class WrappedResultDto( + val result: ResultDto, +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ResultDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ResultDto.kt new file mode 100644 index 00000000000..ea89524f656 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ResultDto.kt @@ -0,0 +1,16 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +enum class ResultDto { + SUCCESS, + + // Not sure about those + FAILURE, + IGNORED, + ; + + fun wrapped() = WrappedResultDto(this) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/StatusInfoDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/StatusInfoDto.kt index 2afc03fadd0..c8d8732f028 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/StatusInfoDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/StatusInfoDto.kt @@ -12,4 +12,5 @@ data class StatusInfoDto( val status: StatusDto, val timesStartedReading: Int, val lastTimeFinished: ZonedDateTime? = null, + val lastTimeStartedReading: ZonedDateTime? = null, )