Skip to content

Commit 64ce165

Browse files
committed
kobo sync read progress
1 parent 2eff7d9 commit 64ce165

File tree

8 files changed

+260
-3
lines changed

8 files changed

+260
-3
lines changed

komga/src/main/kotlin/org/gotson/komga/infrastructure/kobo/KoboHeaders.kt

+1
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ object KoboHeaders {
44
const val X_KOBO_SYNCTOKEN = "x-kobo-synctoken"
55
const val X_KOBO_USERKEY = "X-Kobo-userkey"
66
const val X_KOBO_SYNC = "X-Kobo-sync"
7+
const val X_KOBO_DEVICEID = "X-Kobo-deviceid"
78
}

komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/KoboController.kt

+166-1
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,20 @@ import com.fasterxml.jackson.databind.node.ObjectNode
66
import com.fasterxml.jackson.module.kotlin.treeToValue
77
import io.github.oshai.kotlinlogging.KotlinLogging
88
import org.apache.commons.lang3.RandomStringUtils
9+
import org.gotson.komga.domain.model.Book
910
import org.gotson.komga.domain.model.KomgaSyncToken
11+
import org.gotson.komga.domain.model.R2Device
12+
import org.gotson.komga.domain.model.R2Locator
13+
import org.gotson.komga.domain.model.R2Progression
1014
import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD
1115
import org.gotson.komga.domain.model.SyncPoint
1216
import org.gotson.komga.domain.persistence.BookRepository
17+
import org.gotson.komga.domain.persistence.ReadProgressRepository
1318
import org.gotson.komga.domain.persistence.SyncPointRepository
1419
import org.gotson.komga.domain.service.BookLifecycle
1520
import org.gotson.komga.domain.service.SyncPointLifecycle
1621
import org.gotson.komga.infrastructure.configuration.KomgaProperties
22+
import org.gotson.komga.infrastructure.kobo.KoboHeaders.X_KOBO_DEVICEID
1723
import org.gotson.komga.infrastructure.kobo.KoboHeaders.X_KOBO_SYNC
1824
import org.gotson.komga.infrastructure.kobo.KoboHeaders.X_KOBO_SYNCTOKEN
1925
import org.gotson.komga.infrastructure.kobo.KoboHeaders.X_KOBO_USERKEY
@@ -24,14 +30,24 @@ import org.gotson.komga.infrastructure.web.getCurrentRequest
2430
import org.gotson.komga.interfaces.api.CommonBookController
2531
import org.gotson.komga.interfaces.api.kobo.dto.AuthDto
2632
import org.gotson.komga.interfaces.api.kobo.dto.BookEntitlementContainerDto
33+
import org.gotson.komga.interfaces.api.kobo.dto.BookmarkDto
2734
import org.gotson.komga.interfaces.api.kobo.dto.ChangedEntitlementDto
2835
import org.gotson.komga.interfaces.api.kobo.dto.KoboBookMetadataDto
2936
import org.gotson.komga.interfaces.api.kobo.dto.NewEntitlementDto
37+
import org.gotson.komga.interfaces.api.kobo.dto.ReadingStateDto
38+
import org.gotson.komga.interfaces.api.kobo.dto.ReadingStateUpdateResultDto
39+
import org.gotson.komga.interfaces.api.kobo.dto.RequestResultDto
3040
import org.gotson.komga.interfaces.api.kobo.dto.ResourcesDto
41+
import org.gotson.komga.interfaces.api.kobo.dto.ResultDto
42+
import org.gotson.komga.interfaces.api.kobo.dto.StatisticsDto
43+
import org.gotson.komga.interfaces.api.kobo.dto.StatusDto
44+
import org.gotson.komga.interfaces.api.kobo.dto.StatusInfoDto
3145
import org.gotson.komga.interfaces.api.kobo.dto.SyncResultDto
3246
import org.gotson.komga.interfaces.api.kobo.dto.TestsDto
3347
import org.gotson.komga.interfaces.api.kobo.dto.toBookEntitlementDto
48+
import org.gotson.komga.interfaces.api.kobo.dto.toDto
3449
import org.gotson.komga.interfaces.api.kobo.persistence.KoboDtoRepository
50+
import org.gotson.komga.language.toUTCZoned
3551
import org.springframework.data.domain.Page
3652
import org.springframework.data.domain.Pageable
3753
import org.springframework.http.HttpStatus
@@ -42,6 +58,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal
4258
import org.springframework.web.bind.annotation.GetMapping
4359
import org.springframework.web.bind.annotation.PathVariable
4460
import org.springframework.web.bind.annotation.PostMapping
61+
import org.springframework.web.bind.annotation.PutMapping
4562
import org.springframework.web.bind.annotation.RequestBody
4663
import org.springframework.web.bind.annotation.RequestHeader
4764
import org.springframework.web.bind.annotation.RequestMapping
@@ -52,6 +69,7 @@ import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBo
5269
import org.springframework.web.servlet.support.ServletUriComponentsBuilder
5370
import org.springframework.web.util.UriBuilder
5471
import org.springframework.web.util.UriComponentsBuilder
72+
import java.time.ZonedDateTime
5573
import java.util.UUID
5674

5775
private val logger = KotlinLogging.logger {}
@@ -116,6 +134,7 @@ class KoboController(
116134
private val commonBookController: CommonBookController,
117135
private val bookLifecycle: BookLifecycle,
118136
private val bookRepository: BookRepository,
137+
private val readProgressRepository: ReadProgressRepository,
119138
) {
120139
@GetMapping("ping")
121140
fun ping() = "pong"
@@ -143,6 +162,9 @@ class KoboController(
143162
.body(ResourcesDto(resources))
144163
}
145164

165+
/**
166+
* @return an [AuthDto]
167+
*/
146168
@PostMapping("v1/auth/device")
147169
fun authDevice(
148170
@RequestBody body: JsonNode,
@@ -173,6 +195,9 @@ class KoboController(
173195
testKey = userKey ?: "",
174196
)
175197

198+
/**
199+
* @return an array of [SyncResultDto]
200+
*/
176201
@GetMapping("v1/library/sync")
177202
fun syncLibrary(
178203
@AuthenticationPrincipal principal: KomgaPrincipal,
@@ -222,7 +247,8 @@ class KoboController(
222247

223248
logger.debug { "Library sync: ${booksAdded.numberOfElements} books added, ${booksChanged.numberOfElements} books changed, ${booksRemoved.numberOfElements} books removed" }
224249

225-
val metadata = koboDtoRepository.findBookMetadataByIds((booksAdded.content + booksChanged.content + booksRemoved.content).map { it.bookId }, getDownloadUrlBuilder(authToken)).associateBy { it.entitlementId }
250+
val metadata = koboDtoRepository.findBookMetadataByIds((booksAdded.content + booksChanged.content).map { it.bookId }, getDownloadUrlBuilder(authToken)).associateBy { it.entitlementId }
251+
val readProgress = readProgressRepository.findAllByBookIdsAndUserId((booksAdded.content + booksChanged.content).map { it.bookId }, principal.user.id).associateBy { it.bookId }
226252

227253
buildList {
228254
addAll(
@@ -231,6 +257,7 @@ class KoboController(
231257
BookEntitlementContainerDto(
232258
bookEntitlement = it.toBookEntitlementDto(false),
233259
bookMetadata = metadata[it.bookId]!!,
260+
readingState = readProgress[it.bookId]?.toDto() ?: getEmptyReadProgressForBook(it.bookId, it.createdDate),
234261
),
235262
)
236263
},
@@ -241,6 +268,7 @@ class KoboController(
241268
BookEntitlementContainerDto(
242269
bookEntitlement = it.toBookEntitlementDto(false),
243270
bookMetadata = metadata[it.bookId]!!,
271+
readingState = readProgress[it.bookId]?.toDto() ?: getEmptyReadProgressForBook(it.bookId, it.createdDate),
244272
),
245273
)
246274
},
@@ -264,12 +292,14 @@ class KoboController(
264292
logger.debug { "Library sync: ${books.numberOfElements} books" }
265293

266294
val metadata = koboDtoRepository.findBookMetadataByIds(books.content.map { it.bookId }, getDownloadUrlBuilder(authToken)).associateBy { it.entitlementId }
295+
val readProgress = readProgressRepository.findAllByBookIdsAndUserId(books.content.map { it.bookId }, principal.user.id).associateBy { it.bookId }
267296

268297
books.content.map {
269298
NewEntitlementDto(
270299
BookEntitlementContainerDto(
271300
bookEntitlement = it.toBookEntitlementDto(false),
272301
bookMetadata = metadata[it.bookId]!!,
302+
readingState = readProgress[it.bookId]?.toDto() ?: getEmptyReadProgressForBook(it.bookId, it.createdDate),
273303
),
274304
)
275305
}
@@ -313,6 +343,9 @@ class KoboController(
313343
.body(syncResultMerged)
314344
}
315345

346+
/**
347+
* @return an array of [KoboBookMetadataDto]
348+
*/
316349
@GetMapping("/v1/library/{bookId}/metadata")
317350
fun getBookMetadata(
318351
@PathVariable authToken: String,
@@ -323,6 +356,100 @@ class KoboController(
323356
else
324357
ResponseEntity.ok(koboDtoRepository.findBookMetadataByIds(listOf(bookId), getDownloadUrlBuilder(authToken)))
325358

359+
/**
360+
* @return an array of [ReadingStateDto]
361+
*/
362+
@GetMapping("/v1/library/{bookId}/state")
363+
fun getState(
364+
@AuthenticationPrincipal principal: KomgaPrincipal,
365+
@PathVariable bookId: String,
366+
): ResponseEntity<*> {
367+
val book =
368+
bookRepository.findByIdOrNull(bookId)
369+
?: if (koboProxy.isEnabled())
370+
return koboProxy.proxyCurrentRequest()
371+
else
372+
throw ResponseStatusException(HttpStatus.NOT_FOUND)
373+
374+
val response = readProgressRepository.findByBookIdAndUserIdOrNull(bookId, principal.user.id)?.toDto() ?: getEmptyReadProgressForBook(book)
375+
return ResponseEntity.ok(listOf(response))
376+
}
377+
378+
/**
379+
* @return a [RequestResultDto]
380+
*/
381+
@PutMapping("/v1/library/{bookId}/state")
382+
fun updateState(
383+
@AuthenticationPrincipal principal: KomgaPrincipal,
384+
@PathVariable bookId: String,
385+
@RequestBody koboUpdate: ReadingStateDto,
386+
@RequestHeader(name = X_KOBO_DEVICEID, required = false) koboDeviceId: String = "unknown",
387+
): ResponseEntity<*> {
388+
val book =
389+
bookRepository.findByIdOrNull(bookId)
390+
?: if (koboProxy.isEnabled())
391+
return koboProxy.proxyCurrentRequest(koboUpdate)
392+
else
393+
throw ResponseStatusException(HttpStatus.NOT_FOUND)
394+
395+
if (koboUpdate.currentBookmark.location == null) throw ResponseStatusException(HttpStatus.BAD_REQUEST)
396+
397+
// convert the Kobo update request to an R2Progression
398+
val r2Progression =
399+
R2Progression(
400+
modified = koboUpdate.lastModified,
401+
device =
402+
R2Device(
403+
id = koboDeviceId,
404+
// TODO: get API key comment
405+
name = "need to get the API key comment",
406+
),
407+
locator =
408+
R2Locator(
409+
href = koboUpdate.currentBookmark.location.source,
410+
// assume default
411+
type = "application/xhtml+xml",
412+
locations =
413+
R2Locator.Location(
414+
progression = koboUpdate.currentBookmark.progressPercent,
415+
),
416+
),
417+
)
418+
419+
val response =
420+
try {
421+
bookLifecycle.markProgression(book, principal.user, r2Progression)
422+
423+
RequestResultDto(
424+
requestResult = ResultDto.SUCCESS,
425+
updateResults =
426+
listOf(
427+
ReadingStateUpdateResultDto(
428+
entitlementId = bookId,
429+
currentBookmarkResult = ResultDto.SUCCESS.wrapped(),
430+
statisticsResult = ResultDto.IGNORED.wrapped(),
431+
statusInfoResult = ResultDto.SUCCESS.wrapped(),
432+
),
433+
),
434+
)
435+
} catch (e: Exception) {
436+
RequestResultDto(
437+
requestResult = ResultDto.FAILURE,
438+
updateResults =
439+
listOf(
440+
ReadingStateUpdateResultDto(
441+
entitlementId = bookId,
442+
currentBookmarkResult = ResultDto.FAILURE.wrapped(),
443+
statisticsResult = ResultDto.FAILURE.wrapped(),
444+
statusInfoResult = ResultDto.FAILURE.wrapped(),
445+
),
446+
),
447+
)
448+
}
449+
450+
return ResponseEntity.ok(response)
451+
}
452+
326453
@GetMapping(
327454
value = ["v1/books/{bookId}/file/epub"],
328455
produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE],
@@ -396,4 +523,42 @@ class KoboController(
396523
workId = bookId,
397524
title = bookId,
398525
)
526+
527+
private fun getEmptyReadProgressForBook(book: Book): ReadingStateDto {
528+
val createdDateUTC = book.createdDate.toUTCZoned()
529+
return ReadingStateDto(
530+
created = createdDateUTC,
531+
lastModified = createdDateUTC,
532+
priorityTimestamp = createdDateUTC,
533+
entitlementId = book.id,
534+
currentBookmark = BookmarkDto(createdDateUTC),
535+
statistics = StatisticsDto(createdDateUTC),
536+
statusInfo =
537+
StatusInfoDto(
538+
lastModified = createdDateUTC,
539+
status = StatusDto.READY_TO_READ,
540+
timesStartedReading = 0,
541+
),
542+
)
543+
}
544+
545+
private fun getEmptyReadProgressForBook(
546+
bookId: String,
547+
createdDate: ZonedDateTime,
548+
): ReadingStateDto {
549+
return ReadingStateDto(
550+
created = createdDate,
551+
lastModified = createdDate,
552+
priorityTimestamp = createdDate,
553+
entitlementId = bookId,
554+
currentBookmark = BookmarkDto(createdDate),
555+
statistics = StatisticsDto(createdDate),
556+
statusInfo =
557+
StatusInfoDto(
558+
lastModified = createdDate,
559+
status = StatusDto.READY_TO_READ,
560+
timesStartedReading = 0,
561+
),
562+
)
563+
}
399564
}

komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/BookmarkDto.kt

+8
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,15 @@ import java.time.ZonedDateTime
99
@JsonInclude(JsonInclude.Include.NON_NULL)
1010
data class BookmarkDto(
1111
val lastModified: ZonedDateTime,
12+
/**
13+
* Total progression in the book.
14+
* Between 0 and 100.
15+
*/
1216
val progressPercent: Float? = null,
17+
/**
18+
* Progression within the resource.
19+
* Between 0 and 100.
20+
*/
1321
val contentSourceProgressPercent: Float? = null,
1422
val location: LocationDto? = null,
1523
)

komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/LocationDto.kt

+11-2
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,16 @@ import com.fasterxml.jackson.databind.annotation.JsonNaming
55

66
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class)
77
data class LocationDto(
8-
val value: String,
9-
val type: String,
8+
/**
9+
* For type=KoboSpan values are in the form "kobo.x.y"
10+
*/
11+
val value: String? = null,
12+
/**
13+
* Typically "KoboSpan"
14+
*/
15+
val type: String? = null,
16+
/**
17+
* The epub HTML resource
18+
*/
1019
val source: String,
1120
)

komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ReadingStateDto.kt

+32
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package org.gotson.komga.interfaces.api.kobo.dto
22

33
import com.fasterxml.jackson.databind.PropertyNamingStrategies
44
import com.fasterxml.jackson.databind.annotation.JsonNaming
5+
import org.gotson.komga.domain.model.ReadProgress
6+
import org.gotson.komga.language.toUTCZoned
57
import java.time.ZonedDateTime
68

79
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class)
@@ -17,3 +19,33 @@ data class ReadingStateDto(
1719
val statistics: StatisticsDto,
1820
val statusInfo: StatusInfoDto,
1921
)
22+
23+
fun ReadProgress.toDto() =
24+
ReadingStateDto(
25+
created = this.createdDate.toUTCZoned(),
26+
lastModified = this.lastModifiedDate.toUTCZoned(),
27+
priorityTimestamp = this.lastModifiedDate.toUTCZoned(),
28+
entitlementId = this.bookId,
29+
currentBookmark =
30+
BookmarkDto(
31+
lastModified = this.lastModifiedDate.toUTCZoned(),
32+
progressPercent = this.locator?.locations?.totalProgression,
33+
contentSourceProgressPercent = this.locator?.locations?.progression,
34+
location = this.locator?.let { LocationDto(source = it.href) },
35+
),
36+
statistics =
37+
StatisticsDto(
38+
lastModified = this.lastModifiedDate.toUTCZoned(),
39+
),
40+
statusInfo =
41+
StatusInfoDto(
42+
lastModified = this.lastModifiedDate.toUTCZoned(),
43+
status =
44+
when {
45+
this.completed -> StatusDto.FINISHED
46+
!this.completed -> StatusDto.READING
47+
else -> StatusDto.READY_TO_READ
48+
},
49+
timesStartedReading = 1,
50+
),
51+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package org.gotson.komga.interfaces.api.kobo.dto
2+
3+
import com.fasterxml.jackson.databind.PropertyNamingStrategies
4+
import com.fasterxml.jackson.databind.annotation.JsonNaming
5+
6+
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class)
7+
data class RequestResultDto(
8+
val requestResult: ResultDto,
9+
val updateResults: Collection<UpdateResultDto>,
10+
)
11+
12+
interface UpdateResultDto
13+
14+
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class)
15+
data class ReadingStateUpdateResultDto(
16+
val entitlementId: String,
17+
val currentBookmarkResult: WrappedResultDto,
18+
val statisticsResult: WrappedResultDto,
19+
val statusInfoResult: WrappedResultDto,
20+
) : UpdateResultDto
21+
22+
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class)
23+
data class WrappedResultDto(
24+
val result: ResultDto,
25+
)

0 commit comments

Comments
 (0)