Skip to content

Commit

Permalink
kobo sync read progress
Browse files Browse the repository at this point in the history
  • Loading branch information
gotson committed Aug 20, 2024
1 parent 2eff7d9 commit 64ce165
Show file tree
Hide file tree
Showing 8 changed files with 260 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 {}
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -143,6 +162,9 @@ class KoboController(
.body(ResourcesDto(resources))
}

/**
* @return an [AuthDto]
*/
@PostMapping("v1/auth/device")
fun authDevice(
@RequestBody body: JsonNode,
Expand Down Expand Up @@ -173,6 +195,9 @@ class KoboController(
testKey = userKey ?: "",
)

/**
* @return an array of [SyncResultDto]
*/
@GetMapping("v1/library/sync")
fun syncLibrary(
@AuthenticationPrincipal principal: KomgaPrincipal,
Expand Down Expand Up @@ -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(
Expand All @@ -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),
),
)
},
Expand All @@ -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),
),
)
},
Expand All @@ -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),
),
)
}
Expand Down Expand Up @@ -313,6 +343,9 @@ class KoboController(
.body(syncResultMerged)
}

/**
* @return an array of [KoboBookMetadataDto]
*/
@GetMapping("/v1/library/{bookId}/metadata")
fun getBookMetadata(
@PathVariable authToken: String,
Expand All @@ -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],
Expand Down Expand Up @@ -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,
),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
),
)
Original file line number Diff line number Diff line change
@@ -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<UpdateResultDto>,
)

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,
)
Loading

0 comments on commit 64ce165

Please sign in to comment.