feat(kobo): initial Kobo Sync support

This commit is contained in:
Gauthier Roebroeck 2024-08-27 18:02:16 +08:00
parent a4747e81f4
commit 210c7b1e50
69 changed files with 2863 additions and 47 deletions

View file

@ -0,0 +1,41 @@
CREATE TABLE SYNC_POINT
(
ID varchar NOT NULL PRIMARY KEY,
CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
USER_ID varchar NOT NULL,
API_KEY_ID varchar NULL,
FOREIGN KEY (USER_ID) REFERENCES USER (ID)
);
create index if not exists idx__sync_point__user_id
on SYNC_POINT (USER_ID);
CREATE TABLE SYNC_POINT_BOOK_REMOVED_SYNCED
(
SYNC_POINT_ID varchar NOT NULL,
BOOK_ID varchar NOT NULL,
PRIMARY KEY (SYNC_POINT_ID, BOOK_ID),
FOREIGN KEY (SYNC_POINT_ID) REFERENCES SYNC_POINT (ID)
);
create index if not exists idx__sync_point_book_removed_status__sync_point_id
on SYNC_POINT_BOOK_REMOVED_SYNCED (SYNC_POINT_ID);
CREATE TABLE SYNC_POINT_BOOK
(
SYNC_POINT_ID varchar NOT NULL,
BOOK_ID varchar NOT NULL,
BOOK_CREATED_DATE datetime NOT NULL,
BOOK_LAST_MODIFIED_DATE datetime NOT NULL,
BOOK_FILE_LAST_MODIFIED datetime NOT NULL,
BOOK_FILE_SIZE int8 NOT NULL,
BOOK_FILE_HASH varchar NOT NULL,
BOOK_METADATA_LAST_MODIFIED_DATE datetime NOT NULL,
BOOK_READ_PROGRESS_LAST_MODIFIED_DATE datetime NULL,
SYNCED boolean NOT NULL default false,
PRIMARY KEY (SYNC_POINT_ID, BOOK_ID),
FOREIGN KEY (SYNC_POINT_ID) REFERENCES SYNC_POINT (ID)
);
create index if not exists idx__sync_point_book__sync_point_id
on SYNC_POINT_BOOK (SYNC_POINT_ID);

View file

@ -0,0 +1,6 @@
alter table USER
add column ROLE_KOBO_SYNC boolean NOT NULL DEFAULT 0;
update USER
set ROLE_KOBO_SYNC = 1
where ROLE_ADMIN = 1;

View file

@ -7,6 +7,7 @@ open class BookSearch(
val seriesIds: Collection<String>? = null,
val searchTerm: String? = null,
val mediaStatus: Collection<Media.Status>? = null,
val mediaProfile: Collection<MediaProfile>? = null,
val deleted: Boolean? = null,
val releasedAfter: LocalDate? = null,
)
@ -16,6 +17,7 @@ class BookSearchWithReadProgress(
seriesIds: Collection<String>? = null,
searchTerm: String? = null,
mediaStatus: Collection<Media.Status>? = null,
mediaProfile: Collection<MediaProfile>? = null,
deleted: Boolean? = null,
releasedAfter: LocalDate? = null,
val tags: Collection<String>? = null,
@ -26,6 +28,7 @@ class BookSearchWithReadProgress(
seriesIds = seriesIds,
searchTerm = searchTerm,
mediaStatus = mediaStatus,
mediaProfile = mediaProfile,
deleted = deleted,
releasedAfter = releasedAfter,
)

View file

@ -0,0 +1,14 @@
package org.gotson.komga.domain.model
data class KomgaSyncToken(
val version: Int = 1,
val rawKoboSyncToken: String = "",
/**
* Only if a sync is currently ongoing, else null.
*/
val ongoingSyncPointId: String? = null,
/**
* The last successful SyncPoint ID.
*/
val lastSuccessfulSyncPointId: String? = null,
)

View file

@ -10,6 +10,7 @@ const val ROLE_USER = "USER"
const val ROLE_ADMIN = "ADMIN"
const val ROLE_FILE_DOWNLOAD = "FILE_DOWNLOAD"
const val ROLE_PAGE_STREAMING = "PAGE_STREAMING"
const val ROLE_KOBO_SYNC = "KOBO_SYNC"
data class KomgaUser(
@Email(regexp = ".+@.+\\..+")
@ -20,6 +21,7 @@ data class KomgaUser(
val roleAdmin: Boolean,
val roleFileDownload: Boolean = true,
val rolePageStreaming: Boolean = true,
val roleKoboSync: Boolean = false,
val sharedLibrariesIds: Set<String> = emptySet(),
val sharedAllLibraries: Boolean = true,
val restrictions: ContentRestrictions = ContentRestrictions(),
@ -34,6 +36,7 @@ data class KomgaUser(
if (roleAdmin) add(ROLE_ADMIN)
if (roleFileDownload) add(ROLE_FILE_DOWNLOAD)
if (rolePageStreaming) add(ROLE_PAGE_STREAMING)
if (roleKoboSync) add(ROLE_KOBO_SYNC)
}
}
@ -107,5 +110,5 @@ data class KomgaUser(
}
override fun toString(): String =
"KomgaUser(email='$email', roleAdmin=$roleAdmin, roleFileDownload=$roleFileDownload, rolePageStreaming=$rolePageStreaming, sharedLibrariesIds=$sharedLibrariesIds, sharedAllLibraries=$sharedAllLibraries, restrictions=$restrictions, id='$id', createdDate=$createdDate, lastModifiedDate=$lastModifiedDate)"
"KomgaUser(email='$email', roleAdmin=$roleAdmin, roleFileDownload=$roleFileDownload, rolePageStreaming=$rolePageStreaming, roleKoboSync=$roleKoboSync, sharedLibrariesIds=$sharedLibrariesIds, sharedAllLibraries=$sharedAllLibraries, restrictions=$restrictions, id='$id', createdDate=$createdDate, lastModifiedDate=$lastModifiedDate)"
}

View file

@ -10,6 +10,8 @@ enum class MediaType(val type: String, val profile: MediaProfile, val fileExtens
;
companion object {
fun fromMediaType(mediaType: String?): MediaType? = values().firstOrNull { it.type == mediaType }
fun fromMediaType(mediaType: String?): MediaType? = entries.firstOrNull { it.type == mediaType }
fun matchingMediaProfile(mediaProfile: MediaProfile): Collection<MediaType> = entries.filter { it.profile == mediaProfile }
}
}

View file

@ -0,0 +1,22 @@
package org.gotson.komga.domain.model
import java.time.ZonedDateTime
data class SyncPoint(
val id: String,
val userId: String,
val apiKeyId: String?,
val createdDate: ZonedDateTime,
) {
data class Book(
val syncPointId: String,
val bookId: String,
val createdDate: ZonedDateTime,
val lastModifiedDate: ZonedDateTime,
val fileLastModified: ZonedDateTime,
val fileSize: Long,
val fileHash: String,
val metadataLastModifiedDate: ZonedDateTime,
val synced: Boolean,
)
}

View file

@ -73,6 +73,8 @@ interface BookRepository {
sort: Sort,
): Collection<String>
fun existsById(bookId: String): Boolean
fun insert(book: Book)
fun insert(books: Collection<Book>)

View file

@ -0,0 +1,68 @@
package org.gotson.komga.domain.persistence
import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.SyncPoint
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
interface SyncPointRepository {
fun create(
user: KomgaUser,
apiKeyId: String?,
search: BookSearch,
): SyncPoint
fun findByIdOrNull(syncPointId: String): SyncPoint?
fun findBooksById(
syncPointId: String,
onlyNotSynced: Boolean,
pageable: Pageable,
): Page<SyncPoint.Book>
fun findBooksAdded(
fromSyncPointId: String,
toSyncPointId: String,
onlyNotSynced: Boolean,
pageable: Pageable,
): Page<SyncPoint.Book>
fun findBooksRemoved(
fromSyncPointId: String,
toSyncPointId: String,
onlyNotSynced: Boolean,
pageable: Pageable,
): Page<SyncPoint.Book>
fun findBooksChanged(
fromSyncPointId: String,
toSyncPointId: String,
onlyNotSynced: Boolean,
pageable: Pageable,
): Page<SyncPoint.Book>
fun findBooksReadProgressChanged(
fromSyncPointId: String,
toSyncPointId: String,
onlyNotSynced: Boolean,
pageable: Pageable,
): Page<SyncPoint.Book>
fun markBooksSynced(
syncPointId: String,
forRemovedBooks: Boolean,
bookIds: Collection<String>,
)
fun deleteByUserId(userId: String)
fun deleteByUserIdAndApiKeyIds(
userId: String,
apiKeyIds: Collection<String>,
)
fun deleteOne(syncPointId: String)
fun deleteAll()
}

View file

@ -472,15 +472,18 @@ class BookLifecycle(
// 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
}
if (extension.isFixedLayout && matchingPositions.size == 1)
matchingPositions.first()
else
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(
@ -491,7 +494,8 @@ class BookLifecycle(
newProgression.modified.toLocalDateTime().toCurrentTimeZone(),
newProgression.device.id,
newProgression.device.name,
newProgression.locator,
// use the type we have instead of the one provided
newProgression.locator.copy(type = matchedPosition.type),
)
}
}

View file

@ -1,13 +1,18 @@
package org.gotson.komga.domain.service
import io.github.oshai.kotlinlogging.KotlinLogging
import org.gotson.komga.domain.model.ApiKey
import org.gotson.komga.domain.model.DomainEvent
import org.gotson.komga.domain.model.DuplicateNameException
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.UserEmailAlreadyExistsException
import org.gotson.komga.domain.persistence.AuthenticationActivityRepository
import org.gotson.komga.domain.persistence.KomgaUserRepository
import org.gotson.komga.domain.persistence.ReadProgressRepository
import org.gotson.komga.domain.persistence.SyncPointRepository
import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.infrastructure.security.TokenEncoder
import org.gotson.komga.infrastructure.security.apikey.ApiKeyGenerator
import org.springframework.context.ApplicationEventPublisher
import org.springframework.security.core.session.SessionRegistry
import org.springframework.security.crypto.password.PasswordEncoder
@ -21,10 +26,13 @@ class KomgaUserLifecycle(
private val userRepository: KomgaUserRepository,
private val readProgressRepository: ReadProgressRepository,
private val authenticationActivityRepository: AuthenticationActivityRepository,
private val syncPointRepository: SyncPointRepository,
private val passwordEncoder: PasswordEncoder,
private val tokenEncoder: TokenEncoder,
private val sessionRegistry: SessionRegistry,
private val transactionTemplate: TransactionTemplate,
private val eventPublisher: ApplicationEventPublisher,
private val apiKeyGenerator: ApiKeyGenerator,
) {
fun updatePassword(
user: KomgaUser,
@ -78,6 +86,7 @@ class KomgaUserLifecycle(
transactionTemplate.executeWithoutResult {
readProgressRepository.deleteByUserId(user.id)
authenticationActivityRepository.deleteByUser(user)
syncPointRepository.deleteByUserId(user.id)
userRepository.delete(user.id)
}
@ -95,4 +104,32 @@ class KomgaUserLifecycle(
it.expireNow()
}
}
/**
* Create and persist an API key for the user.
* @return the ApiKey, or null if a unique API key could not be generated
*/
fun createApiKey(
user: KomgaUser,
comment: String,
): ApiKey? {
val commentTrimmed = comment.trim()
if (userRepository.existsApiKeyByCommentAndUserId(commentTrimmed, user.id))
throw DuplicateNameException("api key comment already exists for this user", "ERR_1034")
for (attempt in 1..10) {
try {
val plainTextKey =
ApiKey(
userId = user.id,
key = apiKeyGenerator.generate(),
comment = commentTrimmed,
)
userRepository.insert(plainTextKey.copy(key = tokenEncoder.encode(plainTextKey.key)))
return plainTextKey
} catch (e: Exception) {
logger.debug { "Failed to generate unique api key, attempt #$attempt" }
}
}
return null
}
}

View file

@ -0,0 +1,86 @@
package org.gotson.komga.domain.service
import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.MediaProfile
import org.gotson.komga.domain.model.SyncPoint
import org.gotson.komga.domain.persistence.SyncPointRepository
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Component
@Component
class SyncPointLifecycle(
private val syncPointRepository: SyncPointRepository,
) {
fun createSyncPoint(
user: KomgaUser,
apiKeyId: String?,
libraryIds: List<String>?,
): SyncPoint =
syncPointRepository.create(
user,
apiKeyId,
BookSearch(
libraryIds = user.getAuthorizedLibraryIds(libraryIds),
mediaStatus = setOf(Media.Status.READY),
mediaProfile = listOf(MediaProfile.EPUB),
deleted = false,
),
)
/**
* Retrieve a page of un-synced books and mark them as synced.
*/
fun takeBooks(
toSyncPointId: String,
pageable: Pageable,
): Page<SyncPoint.Book> =
syncPointRepository.findBooksById(toSyncPointId, true, pageable)
.also { page -> syncPointRepository.markBooksSynced(toSyncPointId, false, page.content.map { it.bookId }) }
/**
* Retrieve a page of un-synced added books and mark them as synced.
*/
fun takeBooksAdded(
fromSyncPointId: String,
toSyncPointId: String,
pageable: Pageable,
): Page<SyncPoint.Book> =
syncPointRepository.findBooksAdded(fromSyncPointId, toSyncPointId, true, pageable)
.also { page -> syncPointRepository.markBooksSynced(toSyncPointId, false, page.content.map { it.bookId }) }
/**
* Retrieve a page of un-synced changed books and mark them as synced.
*/
fun takeBooksChanged(
fromSyncPointId: String,
toSyncPointId: String,
pageable: Pageable,
): Page<SyncPoint.Book> =
syncPointRepository.findBooksChanged(fromSyncPointId, toSyncPointId, true, pageable)
.also { page -> syncPointRepository.markBooksSynced(toSyncPointId, false, page.content.map { it.bookId }) }
/**
* Retrieve a page of un-synced removed books and mark them as synced.
*/
fun takeBooksRemoved(
fromSyncPointId: String,
toSyncPointId: String,
pageable: Pageable,
): Page<SyncPoint.Book> =
syncPointRepository.findBooksRemoved(fromSyncPointId, toSyncPointId, true, pageable)
.also { page -> syncPointRepository.markBooksSynced(toSyncPointId, true, page.content.map { it.bookId }) }
/**
* Retrieve a page of un-synced unchanged books with changed read progress and mark them as synced.
*/
fun takeBooksReadProgressChanged(
fromSyncPointId: String,
toSyncPointId: String,
pageable: Pageable,
): Page<SyncPoint.Book> =
syncPointRepository.findBooksReadProgressChanged(fromSyncPointId, toSyncPointId, true, pageable)
.also { page -> syncPointRepository.markBooksSynced(toSyncPointId, false, page.content.map { it.bookId }) }
}

View file

@ -67,6 +67,8 @@ class KomgaProperties {
var configDir: String? = null
var kobo = Kobo()
@Positive
@Deprecated("Artemis has been replaced")
var taskConsumers: Int = 1
@ -130,4 +132,9 @@ class KomgaProperties {
var preserveOriginal: Boolean = true
}
}
class Kobo {
@get:Positive
var syncItemLimit: Int = 100
}
}

View file

@ -85,6 +85,13 @@ class KomgaSettingsProvider(
serverSettingsDao.deleteSetting(Settings.SERVER_CONTEXT_PATH.name)
field = value
}
var koboProxy: Boolean =
serverSettingsDao.getSettingByKey(Settings.KOBO_PROXY.name, Boolean::class.java) ?: false
set(value) {
serverSettingsDao.saveSetting(Settings.KOBO_PROXY.name, value)
field = value
}
}
private enum class Settings {
@ -96,4 +103,5 @@ private enum class Settings {
TASK_POOL_SIZE,
SERVER_PORT,
SERVER_CONTEXT_PATH,
KOBO_PROXY,
}

View file

@ -2,7 +2,10 @@ 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.BookSearch
import org.gotson.komga.domain.model.ContentRestrictions
import org.gotson.komga.domain.model.MediaExtension
import org.gotson.komga.domain.model.MediaType
import org.gotson.komga.infrastructure.datasource.SqliteUdfDataSource
import org.gotson.komga.jooq.main.Tables
import org.jooq.Condition
@ -67,6 +70,21 @@ fun DSLContext.insertTempStrings(
fun DSLContext.selectTempStrings() = this.select(Tables.TEMP_STRING_LIST.STRING).from(Tables.TEMP_STRING_LIST)
fun BookSearch.toCondition(): Condition {
var c: Condition = DSL.noCondition()
if (libraryIds != null) c = c.and(Tables.BOOK.LIBRARY_ID.`in`(libraryIds))
if (!seriesIds.isNullOrEmpty()) c = c.and(Tables.BOOK.SERIES_ID.`in`(seriesIds))
searchTerm?.let { c = c.and(Tables.BOOK_METADATA.TITLE.containsIgnoreCase(it)) }
if (!mediaStatus.isNullOrEmpty()) c = c.and(Tables.MEDIA.STATUS.`in`(mediaStatus))
if (!mediaProfile.isNullOrEmpty()) c = c.and(Tables.MEDIA.MEDIA_TYPE.`in`(mediaProfile.flatMap { profile -> MediaType.matchingMediaProfile(profile).map { it.type } }.toSet()))
if (deleted == true) c = c.and(Tables.BOOK.DELETED_DATE.isNotNull)
if (deleted == false) c = c.and(Tables.BOOK.DELETED_DATE.isNull)
if (releasedAfter != null) c = c.and(Tables.BOOK_METADATA.RELEASE_DATE.gt(releasedAfter))
return c
}
fun ContentRestrictions.toCondition(dsl: DSLContext): Condition {
val ageAllowed =
if (ageRestriction?.restriction == AllowExclude.ALLOW_ONLY) {
@ -127,3 +145,17 @@ inline fun <reified T> ObjectMapper.deserializeJsonGz(gzJson: ByteArray?): T? {
null
}
}
fun ObjectMapper.deserializeMediaExtension(
extensionClass: String?,
extensionBlob: ByteArray?,
): MediaExtension? {
if (extensionClass == null || extensionBlob == null) return null
return try {
GZIPInputStream(extensionBlob.inputStream()).use { gz ->
this.readValue(gz, Class.forName(extensionClass)) as MediaExtension
}
} catch (e: Exception) {
null
}
}

View file

@ -5,11 +5,11 @@ import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.infrastructure.jooq.insertTempStrings
import org.gotson.komga.infrastructure.jooq.selectTempStrings
import org.gotson.komga.infrastructure.jooq.toCondition
import org.gotson.komga.infrastructure.jooq.toOrderBy
import org.gotson.komga.jooq.main.Tables
import org.gotson.komga.jooq.main.tables.records.BookRecord
import org.gotson.komga.language.toCurrentTimeZone
import org.jooq.Condition
import org.jooq.DSLContext
import org.jooq.impl.DSL
import org.springframework.beans.factory.annotation.Value
@ -230,6 +230,9 @@ class BookDao(
.fetch(b.ID)
}
override fun existsById(bookId: String): Boolean =
dsl.fetchExists(b, b.ID.eq(bookId))
override fun findAllByLibraryIdAndMediaTypes(
libraryId: String,
mediaTypes: Collection<String>,
@ -364,20 +367,6 @@ class BookDao(
.groupBy(b.LIBRARY_ID)
.fetchMap(b.LIBRARY_ID, DSL.sum(b.FILE_SIZE))
private fun BookSearch.toCondition(): Condition {
var c: Condition = DSL.trueCondition()
if (libraryIds != null) c = c.and(b.LIBRARY_ID.`in`(libraryIds))
if (!seriesIds.isNullOrEmpty()) c = c.and(b.SERIES_ID.`in`(seriesIds))
searchTerm?.let { c = c.and(d.TITLE.containsIgnoreCase(it)) }
if (!mediaStatus.isNullOrEmpty()) c = c.and(m.STATUS.`in`(mediaStatus))
if (deleted == true) c = c.and(b.DELETED_DATE.isNotNull)
if (deleted == false) c = c.and(b.DELETED_DATE.isNull)
if (releasedAfter != null) c = c.and(d.RELEASE_DATE.gt(releasedAfter))
return c
}
private fun BookRecord.toDomain() =
Book(
name = name,

View file

@ -2,6 +2,7 @@ package org.gotson.komga.infrastructure.jooq.main
import org.gotson.komga.domain.model.BookSearchWithReadProgress
import org.gotson.komga.domain.model.ContentRestrictions
import org.gotson.komga.domain.model.MediaType
import org.gotson.komga.domain.model.ReadList
import org.gotson.komga.domain.model.ReadStatus
import org.gotson.komga.infrastructure.datasource.SqliteUdfDataSource
@ -442,6 +443,7 @@ class BookDtoDao(
if (libraryIds != null) c = c.and(b.LIBRARY_ID.`in`(libraryIds))
if (!seriesIds.isNullOrEmpty()) c = c.and(b.SERIES_ID.`in`(seriesIds))
if (!mediaStatus.isNullOrEmpty()) c = c.and(m.STATUS.`in`(mediaStatus))
if (!mediaProfile.isNullOrEmpty()) c = c.and(m.MEDIA_TYPE.`in`(mediaProfile.flatMap { profile -> MediaType.matchingMediaProfile(profile).map { it.type } }.toSet()))
if (deleted == true) c = c.and(b.DELETED_DATE.isNotNull)
if (deleted == false) c = c.and(b.DELETED_DATE.isNull)
if (releasedAfter != null) c = c.and(d.RELEASE_DATE.gt(releasedAfter))

View file

@ -0,0 +1,113 @@
package org.gotson.komga.infrastructure.jooq.main
import com.fasterxml.jackson.databind.ObjectMapper
import org.gotson.komga.domain.model.MediaExtensionEpub
import org.gotson.komga.infrastructure.jooq.deserializeMediaExtension
import org.gotson.komga.interfaces.api.kobo.dto.ContributorDto
import org.gotson.komga.interfaces.api.kobo.dto.DownloadUrlDto
import org.gotson.komga.interfaces.api.kobo.dto.FormatDto
import org.gotson.komga.interfaces.api.kobo.dto.KoboBookMetadataDto
import org.gotson.komga.interfaces.api.kobo.dto.KoboSeriesDto
import org.gotson.komga.interfaces.api.kobo.dto.PublisherDto
import org.gotson.komga.interfaces.api.kobo.persistence.KoboDtoRepository
import org.gotson.komga.jooq.main.Tables
import org.jooq.DSLContext
import org.springframework.stereotype.Component
import org.springframework.web.util.UriBuilder
import java.time.ZoneId
@Component
class KoboDtoDao(
private val dsl: DSLContext,
private val mapper: ObjectMapper,
) : KoboDtoRepository {
private val b = Tables.BOOK
private val m = Tables.MEDIA
private val d = Tables.BOOK_METADATA
private val a = Tables.BOOK_METADATA_AUTHOR
private val sd = Tables.SERIES_METADATA
override fun findBookMetadataByIds(
bookIds: Collection<String>,
downloadUriBuilder: UriBuilder,
): Collection<KoboBookMetadataDto> {
val records =
dsl.select(
d.BOOK_ID,
d.TITLE,
d.NUMBER,
d.NUMBER_SORT,
d.ISBN,
d.SUMMARY,
d.RELEASE_DATE,
d.CREATED_DATE,
sd.SERIES_ID,
sd.TITLE,
sd.PUBLISHER,
sd.LANGUAGE,
b.FILE_SIZE,
b.ONESHOT,
m.EXTENSION_CLASS,
m.EXTENSION_VALUE_BLOB,
).from(b)
.leftJoin(d).on(b.ID.eq(d.BOOK_ID))
.leftJoin(sd).on(b.SERIES_ID.eq(sd.SERIES_ID))
.leftJoin(m).on(b.ID.eq(m.BOOK_ID))
.where(d.BOOK_ID.`in`(bookIds))
.fetch()
return records.map { rec ->
val br = rec.into(b)
val dr = rec.into(d)
val sr = rec.into(sd)
val mr = rec.into(m)
val mediaExtension = mapper.deserializeMediaExtension(mr.extensionClass, mr.extensionValueBlob) as? MediaExtensionEpub
val authors =
dsl.selectFrom(a)
.where(a.BOOK_ID.`in`(bookIds))
.filter { it.name != null }
.groupBy({ it.bookId }, { it })
KoboBookMetadataDto(
contributorRoles = authors[dr.bookId].orEmpty().map { ContributorDto(it.name) },
contributors = authors[dr.bookId].orEmpty().map { it.name },
coverImageId = dr.bookId,
crossRevisionId = dr.bookId,
// if null or empty Kobo will not update it, force it to blank
description = dr.summary.ifEmpty { " " },
downloadUrls =
listOf(
DownloadUrlDto(
format = if (mediaExtension?.isFixedLayout == true) FormatDto.EPUB3FL else FormatDto.EPUB3,
size = br.fileSize,
url = downloadUriBuilder.build(dr.bookId).toURL().toString(),
),
DownloadUrlDto(
format = FormatDto.EPUB,
size = br.fileSize,
url = downloadUriBuilder.build(dr.bookId).toURL().toString(),
),
),
entitlementId = dr.bookId,
isbn = dr.isbn.ifBlank { null },
language = sr.language.take(2).ifBlank { "en" },
publicationDate = dr.releaseDate?.atStartOfDay(ZoneId.of("Z")) ?: dr.createdDate.atZone(ZoneId.of("Z")),
publisher = PublisherDto(name = sr.publisher),
revisionId = dr.bookId,
series =
if (!br.oneshot)
KoboSeriesDto(
id = sr.seriesId,
name = sr.title,
number = dr.number,
numberFloat = dr.numberSort,
)
else
null,
title = dr.title,
workId = dr.bookId,
)
}
}
}

View file

@ -9,6 +9,7 @@ import org.gotson.komga.domain.model.MediaExtension
import org.gotson.komga.domain.model.MediaFile
import org.gotson.komga.domain.model.ProxyExtension
import org.gotson.komga.domain.persistence.MediaRepository
import org.gotson.komga.infrastructure.jooq.deserializeMediaExtension
import org.gotson.komga.infrastructure.jooq.insertTempStrings
import org.gotson.komga.infrastructure.jooq.selectTempStrings
import org.gotson.komga.infrastructure.jooq.serializeJsonGz
@ -24,7 +25,6 @@ import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.zip.GZIPInputStream
private val logger = KotlinLogging.logger {}
@ -64,7 +64,7 @@ class MediaDao(
.from(m)
.where(m.BOOK_ID.eq(bookId))
.fetchOne()
?.map { deserializeExtension(it.get(m.EXTENSION_CLASS), it.get(m.EXTENSION_VALUE_BLOB)) }
?.map { mapper.deserializeMediaExtension(it.get(m.EXTENSION_CLASS), it.get(m.EXTENSION_VALUE_BLOB)) }
override fun findAllBookIdsByLibraryIdAndMediaTypeAndWithMissingPageHash(
libraryId: String,
@ -290,21 +290,6 @@ class MediaDao(
lastModifiedDate = lastModifiedDate.toCurrentTimeZone(),
)
fun deserializeExtension(
extensionClass: String?,
extensionBlob: ByteArray?,
): MediaExtension? {
if (extensionClass == null || extensionBlob == null) return null
return try {
GZIPInputStream(extensionBlob.inputStream()).use { gz ->
mapper.readValue(gz, Class.forName(extensionClass)) as MediaExtension
}
} catch (e: Exception) {
logger.error(e) { "Could not deserialize media extension class: $extensionClass" }
null
}
}
private fun MediaPageRecord.toDomain() =
BookPage(
fileName = fileName,

View file

@ -0,0 +1,335 @@
package org.gotson.komga.infrastructure.jooq.main
import com.github.f4b6a3.tsid.TsidCreator
import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.SyncPoint
import org.gotson.komga.domain.persistence.SyncPointRepository
import org.gotson.komga.infrastructure.jooq.toCondition
import org.gotson.komga.jooq.main.Tables
import org.gotson.komga.language.toZonedDateTime
import org.jooq.DSLContext
import org.jooq.SelectConditionStep
import org.jooq.impl.DSL
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.time.ZoneId
@Component
class SyncPointDao(
private val dsl: DSLContext,
) : SyncPointRepository {
private val b = Tables.BOOK
private val m = Tables.MEDIA
private val d = Tables.BOOK_METADATA
private val r = Tables.READ_PROGRESS
private val sd = Tables.SERIES_METADATA
private val sp = Tables.SYNC_POINT
private val spb = Tables.SYNC_POINT_BOOK
private val spbs = Tables.SYNC_POINT_BOOK_REMOVED_SYNCED
@Transactional
override fun create(
user: KomgaUser,
apiKeyId: String?,
search: BookSearch,
): SyncPoint {
val conditions = search.toCondition().and(user.restrictions.toCondition(dsl))
val syncPointId = TsidCreator.getTsid256().toString()
val createdAt = LocalDateTime.now()
dsl.insertInto(
sp,
sp.ID,
sp.USER_ID,
sp.API_KEY_ID,
sp.CREATED_DATE,
).values(
syncPointId,
user.id,
apiKeyId,
createdAt,
).execute()
dsl.insertInto(
spb,
spb.SYNC_POINT_ID,
spb.BOOK_ID,
spb.BOOK_CREATED_DATE,
spb.BOOK_LAST_MODIFIED_DATE,
spb.BOOK_FILE_LAST_MODIFIED,
spb.BOOK_FILE_SIZE,
spb.BOOK_FILE_HASH,
spb.BOOK_METADATA_LAST_MODIFIED_DATE,
spb.BOOK_READ_PROGRESS_LAST_MODIFIED_DATE,
).select(
dsl.select(
DSL.`val`(syncPointId),
b.ID,
b.CREATED_DATE,
b.LAST_MODIFIED_DATE,
b.FILE_LAST_MODIFIED,
b.FILE_SIZE,
b.FILE_HASH,
d.LAST_MODIFIED_DATE,
r.LAST_MODIFIED_DATE,
).from(b)
.join(m).on(b.ID.eq(m.BOOK_ID))
.join(d).on(b.ID.eq(d.BOOK_ID))
.join(sd).on(b.SERIES_ID.eq(sd.SERIES_ID))
.leftJoin(r).on(b.ID.eq(r.BOOK_ID)).and(r.USER_ID.eq(user.id))
.where(conditions),
).execute()
return findByIdOrNull(syncPointId)!!
}
override fun findByIdOrNull(syncPointId: String): SyncPoint? =
dsl.selectFrom(sp)
.where(sp.ID.eq(syncPointId))
.fetchInto(sp)
.map {
SyncPoint(
id = it.id,
userId = it.userId,
apiKeyId = it.apiKeyId,
createdDate = it.createdDate.toZonedDateTime(),
)
}.firstOrNull()
override fun findBooksById(
syncPointId: String,
onlyNotSynced: Boolean,
pageable: Pageable,
): Page<SyncPoint.Book> {
val query =
dsl.selectFrom(spb)
.where(spb.SYNC_POINT_ID.eq(syncPointId))
.apply {
if (onlyNotSynced) {
and(spb.SYNCED.isFalse)
}
}
return queryToPage(query, pageable)
}
override fun findBooksAdded(
fromSyncPointId: String,
toSyncPointId: String,
onlyNotSynced: Boolean,
pageable: Pageable,
): Page<SyncPoint.Book> {
val query =
dsl.selectFrom(spb)
.where(spb.SYNC_POINT_ID.eq(toSyncPointId))
.apply {
if (onlyNotSynced) {
and(spb.SYNCED.isFalse)
}
}
.and(
spb.BOOK_ID.notIn(
dsl.select(spb.BOOK_ID).from(spb).where(spb.SYNC_POINT_ID.eq(fromSyncPointId)),
),
)
return queryToPage(query, pageable)
}
override fun findBooksRemoved(
fromSyncPointId: String,
toSyncPointId: String,
onlyNotSynced: Boolean,
pageable: Pageable,
): Page<SyncPoint.Book> {
val query =
dsl.selectFrom(spb)
.where(spb.SYNC_POINT_ID.eq(fromSyncPointId))
.and(
spb.BOOK_ID.notIn(
dsl.select(spb.BOOK_ID).from(spb).where(spb.SYNC_POINT_ID.eq(toSyncPointId)),
),
)
.apply {
if (onlyNotSynced)
and(
spb.BOOK_ID.notIn(
dsl.select(spbs.BOOK_ID).from(spbs).where(spbs.SYNC_POINT_ID.eq(toSyncPointId)),
),
)
}
return queryToPage(query, pageable)
}
override fun findBooksChanged(
fromSyncPointId: String,
toSyncPointId: String,
onlyNotSynced: Boolean,
pageable: Pageable,
): Page<SyncPoint.Book> {
val spbFrom = spb.`as`("spbFrom")
val query =
dsl.select(*spb.fields())
.from(spb)
.join(spbFrom).on(spb.BOOK_ID.eq(spbFrom.BOOK_ID))
.where(spb.SYNC_POINT_ID.eq(toSyncPointId))
.and(spbFrom.SYNC_POINT_ID.eq(fromSyncPointId))
.apply {
if (onlyNotSynced) {
and(spb.SYNCED.isFalse)
}
}
.and(
spb.BOOK_FILE_LAST_MODIFIED.ne(spbFrom.BOOK_FILE_LAST_MODIFIED)
.or(spb.BOOK_FILE_SIZE.ne(spbFrom.BOOK_FILE_SIZE))
.or(spb.BOOK_FILE_HASH.ne(spbFrom.BOOK_FILE_HASH).and(spbFrom.BOOK_FILE_HASH.isNotNull))
.or(spb.BOOK_METADATA_LAST_MODIFIED_DATE.ne(spbFrom.BOOK_METADATA_LAST_MODIFIED_DATE)),
)
return queryToPage(query, pageable)
}
override fun findBooksReadProgressChanged(
fromSyncPointId: String,
toSyncPointId: String,
onlyNotSynced: Boolean,
pageable: Pageable,
): Page<SyncPoint.Book> {
val spbFrom = spb.`as`("spbFrom")
val query =
dsl.select(*spb.fields())
.from(spb)
.join(spbFrom).on(spb.BOOK_ID.eq(spbFrom.BOOK_ID))
.where(spb.SYNC_POINT_ID.eq(toSyncPointId))
.and(spbFrom.SYNC_POINT_ID.eq(fromSyncPointId))
.apply {
if (onlyNotSynced) {
and(spb.SYNCED.isFalse)
}
}
.and(
// unchanged book
spb.BOOK_FILE_LAST_MODIFIED.eq(spbFrom.BOOK_FILE_LAST_MODIFIED)
.and(spb.BOOK_FILE_SIZE.eq(spbFrom.BOOK_FILE_SIZE))
.and(spb.BOOK_FILE_HASH.eq(spbFrom.BOOK_FILE_HASH).or(spbFrom.BOOK_FILE_HASH.isNull))
.and(spb.BOOK_METADATA_LAST_MODIFIED_DATE.eq(spbFrom.BOOK_METADATA_LAST_MODIFIED_DATE))
// with changed read progress
.and(
spb.BOOK_READ_PROGRESS_LAST_MODIFIED_DATE.ne(spbFrom.BOOK_READ_PROGRESS_LAST_MODIFIED_DATE)
.or(spb.BOOK_READ_PROGRESS_LAST_MODIFIED_DATE.isNull.and(spbFrom.BOOK_READ_PROGRESS_LAST_MODIFIED_DATE.isNotNull))
.or(spb.BOOK_READ_PROGRESS_LAST_MODIFIED_DATE.isNotNull.and(spbFrom.BOOK_READ_PROGRESS_LAST_MODIFIED_DATE.isNull)),
),
)
return queryToPage(query, pageable)
}
override fun markBooksSynced(
syncPointId: String,
forRemovedBooks: Boolean,
bookIds: Collection<String>,
) {
// removed books are not present in the 'to' SyncPoint, only in the 'from' SyncPoint
// we store status in a separate table
if (bookIds.isNotEmpty()) {
if (forRemovedBooks)
dsl.batch(
dsl.insertInto(spbs, spbs.SYNC_POINT_ID, spbs.BOOK_ID).values(null as String?, null).onDuplicateKeyIgnore(),
).also { step ->
bookIds.map { step.bind(syncPointId, it) }
}.execute()
else
dsl.update(spb)
.set(spb.SYNCED, true)
.where(spb.SYNC_POINT_ID.eq(syncPointId))
.and(spb.BOOK_ID.`in`(bookIds))
.execute()
}
}
override fun deleteByUserId(userId: String) {
dsl.deleteFrom(spbs).where(
spbs.SYNC_POINT_ID.`in`(
dsl.select(sp.ID).from(sp).where(sp.USER_ID.eq(userId)),
),
).execute()
dsl.deleteFrom(spb).where(
spb.SYNC_POINT_ID.`in`(
dsl.select(sp.ID).from(sp).where(sp.USER_ID.eq(userId)),
),
).execute()
dsl.deleteFrom(sp).where(sp.USER_ID.eq(userId)).execute()
}
override fun deleteByUserIdAndApiKeyIds(
userId: String,
apiKeyIds: Collection<String>,
) {
dsl.deleteFrom(spbs).where(
spbs.SYNC_POINT_ID.`in`(
dsl.select(sp.ID).from(sp).where(sp.USER_ID.eq(userId).and(sp.API_KEY_ID.`in`(apiKeyIds))),
),
).execute()
dsl.deleteFrom(spb).where(
spb.SYNC_POINT_ID.`in`(
dsl.select(sp.ID).from(sp).where(sp.USER_ID.eq(userId).and(sp.API_KEY_ID.`in`(apiKeyIds))),
),
).execute()
dsl.deleteFrom(sp).where(sp.USER_ID.eq(userId).and(sp.API_KEY_ID.`in`(apiKeyIds))).execute()
}
override fun deleteOne(syncPointId: String) {
dsl.deleteFrom(spbs).where(spbs.SYNC_POINT_ID.eq(syncPointId)).execute()
dsl.deleteFrom(spb).where(spb.SYNC_POINT_ID.eq(syncPointId)).execute()
dsl.deleteFrom(sp).where(sp.ID.eq(syncPointId)).execute()
}
override fun deleteAll() {
dsl.deleteFrom(spbs).execute()
dsl.deleteFrom(spb).execute()
dsl.deleteFrom(sp).execute()
}
private fun queryToPage(
query: SelectConditionStep<*>,
pageable: Pageable,
): Page<SyncPoint.Book> {
val count = dsl.fetchCount(query)
val items =
query
.apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) }
.fetchInto(spb)
.map {
SyncPoint.Book(
syncPointId = it.syncPointId,
bookId = it.bookId,
createdDate = it.bookCreatedDate.atZone(ZoneId.of("Z")),
lastModifiedDate = it.bookLastModifiedDate.atZone(ZoneId.of("Z")),
fileLastModified = it.bookFileLastModified.atZone(ZoneId.of("Z")),
fileSize = it.bookFileSize,
fileHash = it.bookFileHash,
metadataLastModifiedDate = it.bookMetadataLastModifiedDate.atZone(ZoneId.of("Z")),
synced = it.synced,
)
}
return PageImpl(
items,
if (pageable.isPaged)
PageRequest.of(pageable.pageNumber, pageable.pageSize, Sort.unsorted())
else
PageRequest.of(0, maxOf(count, 20), Sort.unsorted()),
count.toLong(),
)
}
}

View file

@ -0,0 +1,8 @@
package org.gotson.komga.infrastructure.kobo
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"
}

View file

@ -0,0 +1,309 @@
package org.gotson.komga.infrastructure.kobo
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import io.github.oshai.kotlinlogging.KotlinLogging
import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider
import org.gotson.komga.infrastructure.kobo.KoboHeaders.X_KOBO_SYNCTOKEN
import org.gotson.komga.infrastructure.web.getCurrentRequest
import org.gotson.komga.language.contains
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatusCode
import org.springframework.http.ResponseEntity
import org.springframework.http.client.ReactorNettyClientRequestFactory
import org.springframework.stereotype.Component
import org.springframework.util.LinkedMultiValueMap
import org.springframework.web.client.RestClient
import org.springframework.web.client.toEntity
import org.springframework.web.server.ResponseStatusException
import kotlin.time.Duration.Companion.minutes
import kotlin.time.toJavaDuration
private val logger = KotlinLogging.logger {}
@Component
class KoboProxy(
private val objectMapper: ObjectMapper,
private val komgaSyncTokenGenerator: KomgaSyncTokenGenerator,
private val komgaSettingsProvider: KomgaSettingsProvider,
) {
private val koboApiClient =
RestClient.builder()
.baseUrl("https://storeapi.kobo.com")
.requestFactory(
ReactorNettyClientRequestFactory().apply {
setConnectTimeout(1.minutes.toJavaDuration())
setReadTimeout(1.minutes.toJavaDuration())
setExchangeTimeout(1.minutes.toJavaDuration())
},
)
.build()
private val pathRegex = """\/kobo\/[-\w]*(.*)""".toRegex()
private val headersOutInclude =
setOf(
HttpHeaders.AUTHORIZATION,
HttpHeaders.USER_AGENT,
HttpHeaders.ACCEPT,
HttpHeaders.ACCEPT_LANGUAGE,
)
private val headersOutExclude =
setOf(
X_KOBO_SYNCTOKEN,
)
private fun isKoboHeader(headerName: String) = headerName.startsWith("x-kobo-", true)
fun isEnabled() = komgaSettingsProvider.koboProxy
/**
* Proxy the current request to the Kobo store, if enabled.
* If [includeSyncToken] is set, the raw sync token will be extracted from the current request and sent to the store.
* If a X_KOBO_SYNCTOKEN header is present in the response, the original Komga sync token will be updated with the
* raw Kobo sync token returned, and added to the response headers.
*/
fun proxyCurrentRequest(
body: Any? = null,
includeSyncToken: Boolean = false,
): ResponseEntity<JsonNode> {
if (!komgaSettingsProvider.koboProxy) throw IllegalStateException("kobo proxying is disabled")
val request = getCurrentRequest()
val (path) = pathRegex.find(request.requestURI)?.destructured ?: throw IllegalStateException("Could not get path from current request")
val syncToken =
if (includeSyncToken)
komgaSyncTokenGenerator.fromRequestHeaders(request)
else
null
val response =
koboApiClient.method(HttpMethod.valueOf(request.method))
.uri { uriBuilder ->
uriBuilder.path(path)
.queryParams(LinkedMultiValueMap(request.parameterMap.mapValues { it.value.toList() }))
.build()
.also { logger.debug { "Proxy URL: $it" } }
}
.headers { headersOut ->
request.headerNames.toList()
.filterNot { headersOutExclude.contains(it, true) }
.filter { headersOutInclude.contains(it, true) || isKoboHeader(it) }
.forEach {
headersOut.addAll(it, request.getHeaders(it)?.toList() ?: emptyList())
}
if (includeSyncToken) {
if (syncToken != null && syncToken.rawKoboSyncToken.isNotBlank()) {
headersOut.add(X_KOBO_SYNCTOKEN, syncToken.rawKoboSyncToken)
} else {
throw IllegalStateException("request must include sync token, but no raw Kobo sync token found")
}
}
logger.debug { "Headers out: $headersOut" }
}
.apply { if (body != null) body(body) }
.retrieve()
.onStatus(HttpStatusCode::isError) { _, response ->
throw ResponseStatusException(response.statusCode, response.statusText)
}
.toEntity<JsonNode>()
logger.debug { "Kobo response: $response" }
val headersToReturn =
response.headers
.filterKeys { isKoboHeader(it) }
.toMutableMap()
.apply {
if (keys.contains(X_KOBO_SYNCTOKEN, true)) {
val koboSyncToken = this[X_KOBO_SYNCTOKEN]?.firstOrNull()
if (koboSyncToken != null && includeSyncToken && syncToken != null) {
val komgaSyncToken = syncToken.copy(rawKoboSyncToken = koboSyncToken)
this[X_KOBO_SYNCTOKEN] = listOf(komgaSyncTokenGenerator.toBase64(komgaSyncToken))
}
}
}
return ResponseEntity(
response.body,
LinkedMultiValueMap(headersToReturn),
response.statusCode,
)
}
val imageHostUrl = "https://cdn.kobo.com/book-images/{ImageId}/{Width}/{Height}/false/image.jpg"
val nativeKoboResources: JsonNode by lazy {
objectMapper.readTree(
// language=JSON
"""
{
"account_page": "https://www.kobo.com/account/settings",
"account_page_rakuten": "https://my.rakuten.co.jp/",
"add_device": "https://storeapi.kobo.com/v1/user/add-device",
"add_entitlement": "https://storeapi.kobo.com/v1/library/{RevisionIds}",
"affiliaterequest": "https://storeapi.kobo.com/v1/affiliate",
"assets": "https://storeapi.kobo.com/v1/assets",
"audiobook": "https://storeapi.kobo.com/v1/products/audiobooks/{ProductId}",
"audiobook_detail_page": "https://www.kobo.com/{region}/{language}/audiobook/{slug}",
"audiobook_get_credits": "https://www.kobo.com/{region}/{language}/audiobooks/plans",
"audiobook_landing_page": "https://www.kobo.com/{region}/{language}/audiobooks",
"audiobook_preview": "https://storeapi.kobo.com/v1/products/audiobooks/{Id}/preview",
"audiobook_purchase_withcredit": "https://storeapi.kobo.com/v1/store/audiobook/{Id}",
"audiobook_subscription_management": "https://www.kobo.com/{region}/{language}/account/subscriptions",
"audiobook_subscription_orange_deal_inclusion_url": "https://authorize.kobo.com/inclusion",
"audiobook_subscription_purchase": "https://www.kobo.com/{region}/{language}/checkoutoption/21C6D938-934B-4A91-B979-E14D70B2F280",
"audiobook_subscription_tiers": "https://www.kobo.com/{region}/{language}/checkoutoption/21C6D938-934B-4A91-B979-E14D70B2F280",
"authorproduct_recommendations": "https://storeapi.kobo.com/v1/products/books/authors/recommendations",
"autocomplete": "https://storeapi.kobo.com/v1/products/autocomplete",
"blackstone_header": {
"key": "x-amz-request-payer",
"value": "requester"
},
"book": "https://storeapi.kobo.com/v1/products/books/{ProductId}",
"book_detail_page": "https://www.kobo.com/{region}/{language}/ebook/{slug}",
"book_detail_page_rakuten": "http://books.rakuten.co.jp/rk/{crossrevisionid}",
"book_landing_page": "https://www.kobo.com/ebooks",
"book_subscription": "https://storeapi.kobo.com/v1/products/books/subscriptions",
"browse_history": "https://storeapi.kobo.com/v1/user/browsehistory",
"categories": "https://storeapi.kobo.com/v1/categories",
"categories_page": "https://www.kobo.com/ebooks/categories",
"category": "https://storeapi.kobo.com/v1/categories/{CategoryId}",
"category_featured_lists": "https://storeapi.kobo.com/v1/categories/{CategoryId}/featured",
"category_products": "https://storeapi.kobo.com/v1/categories/{CategoryId}/products",
"checkout_borrowed_book": "https://storeapi.kobo.com/v1/library/borrow",
"client_authd_referral": "https://authorize.kobo.com/api/AuthenticatedReferral/client/v1/getLink",
"configuration_data": "https://storeapi.kobo.com/v1/configuration",
"content_access_book": "https://storeapi.kobo.com/v1/products/books/{ProductId}/access",
"customer_care_live_chat": "https://v2.zopim.com/widget/livechat.html?key=Y6gwUmnu4OATxN3Tli4Av9bYN319BTdO",
"daily_deal": "https://storeapi.kobo.com/v1/products/dailydeal",
"deals": "https://storeapi.kobo.com/v1/deals",
"delete_entitlement": "https://storeapi.kobo.com/v1/library/{Ids}",
"delete_tag": "https://storeapi.kobo.com/v1/library/tags/{TagId}",
"delete_tag_items": "https://storeapi.kobo.com/v1/library/tags/{TagId}/items/delete",
"device_auth": "https://storeapi.kobo.com/v1/auth/device",
"device_refresh": "https://storeapi.kobo.com/v1/auth/refresh",
"dictionary_host": "https://ereaderfiles.kobo.com",
"discovery_host": "https://discovery.kobobooks.com",
"dropbox_link_account_poll": "https://authorize.kobo.com/{region}/{language}/LinkDropbox",
"dropbox_link_account_start": "https://authorize.kobo.com/LinkDropbox/start",
"eula_page": "https://www.kobo.com/termsofuse?style=onestore",
"exchange_auth": "https://storeapi.kobo.com/v1/auth/exchange",
"external_book": "https://storeapi.kobo.com/v1/products/books/external/{Ids}",
"facebook_sso_page": "https://authorize.kobo.com/signin/provider/Facebook/login?returnUrl=http://kobo.com/",
"featured_list": "https://storeapi.kobo.com/v1/products/featured/{FeaturedListId}",
"featured_lists": "https://storeapi.kobo.com/v1/products/featured",
"free_books_page": {
"EN": "https://www.kobo.com/{region}/{language}/p/free-ebooks",
"FR": "https://www.kobo.com/{region}/{language}/p/livres-gratuits",
"IT": "https://www.kobo.com/{region}/{language}/p/libri-gratuiti",
"NL": "https://www.kobo.com/{region}/{language}/List/bekijk-het-overzicht-van-gratis-ebooks/QpkkVWnUw8sxmgjSlCbJRg",
"PT": "https://www.kobo.com/{region}/{language}/p/livros-gratis"
},
"fte_feedback": "https://storeapi.kobo.com/v1/products/ftefeedback",
"funnel_metrics": "https://storeapi.kobo.com/v1/funnelmetrics",
"get_download_keys": "https://storeapi.kobo.com/v1/library/downloadkeys",
"get_download_link": "https://storeapi.kobo.com/v1/library/downloadlink",
"get_tests_request": "https://storeapi.kobo.com/v1/analytics/gettests",
"giftcard_epd_redeem_url": "https://www.kobo.com/{storefront}/{language}/redeem-ereader",
"giftcard_redeem_url": "https://www.kobo.com/{storefront}/{language}/redeem",
"googledrive_link_account_start": "https://authorize.kobo.com/{region}/{language}/linkcloudstorage/provider/google_drive",
"gpb_flow_enabled": "False",
"help_page": "http://www.kobo.com/help",
"image_host": "//cdn.kobo.com/book-images/",
"image_url_quality_template": "https://cdn.kobo.com/book-images/{ImageId}/{Width}/{Height}/{Quality}/{IsGreyscale}/image.jpg",
"image_url_template": "https://cdn.kobo.com/book-images/{ImageId}/{Width}/{Height}/false/image.jpg",
"kobo_audiobooks_credit_redemption": "True",
"kobo_audiobooks_enabled": "True",
"kobo_audiobooks_orange_deal_enabled": "True",
"kobo_audiobooks_subscriptions_enabled": "True",
"kobo_display_price": "True",
"kobo_dropbox_link_account_enabled": "True",
"kobo_google_tax": "False",
"kobo_googledrive_link_account_enabled": "True",
"kobo_nativeborrow_enabled": "False",
"kobo_onedrive_link_account_enabled": "False",
"kobo_onestorelibrary_enabled": "False",
"kobo_privacyCentre_url": "https://www.kobo.com/privacy",
"kobo_redeem_enabled": "True",
"kobo_shelfie_enabled": "False",
"kobo_subscriptions_enabled": "True",
"kobo_superpoints_enabled": "False",
"kobo_wishlist_enabled": "True",
"library_book": "https://storeapi.kobo.com/v1/user/library/books/{LibraryItemId}",
"library_items": "https://storeapi.kobo.com/v1/user/library",
"library_metadata": "https://storeapi.kobo.com/v1/library/{Ids}/metadata",
"library_prices": "https://storeapi.kobo.com/v1/user/library/previews/prices",
"library_search": "https://storeapi.kobo.com/v1/library/search",
"library_sync": "https://storeapi.kobo.com/v1/library/sync",
"love_dashboard_page": "https://www.kobo.com/{region}/{language}/kobosuperpoints",
"love_points_redemption_page": "https://www.kobo.com/{region}/{language}/KoboSuperPointsRedemption?productId={ProductId}",
"magazine_landing_page": "https://www.kobo.com/emagazines",
"more_sign_in_options": "https://authorize.kobo.com/signin?returnUrl=http://kobo.com/#allProviders",
"notebooks": "https://storeapi.kobo.com/api/internal/notebooks",
"notifications_registration_issue": "https://storeapi.kobo.com/v1/notifications/registration",
"oauth_host": "https://oauth.kobo.com",
"password_retrieval_page": "https://www.kobo.com/passwordretrieval.html",
"personalizedrecommendations": "https://storeapi.kobo.com/v2/users/personalizedrecommendations",
"pocket_link_account_start": "https://authorize.kobo.com/{region}/{language}/linkpocket",
"post_analytics_event": "https://storeapi.kobo.com/v1/analytics/event",
"ppx_purchasing_url": "https://purchasing.kobo.com",
"privacy_page": "https://www.kobo.com/privacypolicy?style=onestore",
"product_nextread": "https://storeapi.kobo.com/v1/products/{ProductIds}/nextread",
"product_prices": "https://storeapi.kobo.com/v1/products/{ProductIds}/prices",
"product_recommendations": "https://storeapi.kobo.com/v1/products/{ProductId}/recommendations",
"product_reviews": "https://storeapi.kobo.com/v1/products/{ProductIds}/reviews",
"products": "https://storeapi.kobo.com/v1/products",
"productsv2": "https://storeapi.kobo.com/v2/products",
"provider_external_sign_in_page": "https://authorize.kobo.com/ExternalSignIn/{providerName}?returnUrl=http://kobo.com/",
"purchase_buy": "https://www.kobo.com/checkoutoption/",
"purchase_buy_templated": "https://www.kobo.com/{region}/{language}/checkoutoption/{ProductId}",
"quickbuy_checkout": "https://storeapi.kobo.com/v1/store/quickbuy/{PurchaseId}/checkout",
"quickbuy_create": "https://storeapi.kobo.com/v1/store/quickbuy/purchase",
"rakuten_token_exchange": "https://storeapi.kobo.com/v1/auth/rakuten_token_exchange",
"rating": "https://storeapi.kobo.com/v1/products/{ProductId}/rating/{Rating}",
"reading_services_host": "https://readingservices.kobo.com",
"reading_state": "https://storeapi.kobo.com/v1/library/{Ids}/state",
"redeem_interstitial_page": "https://www.kobo.com",
"registration_page": "https://authorize.kobo.com/signup?returnUrl=http://kobo.com/",
"related_items": "https://storeapi.kobo.com/v1/products/{Id}/related",
"remaining_book_series": "https://storeapi.kobo.com/v1/products/books/series/{SeriesId}",
"rename_tag": "https://storeapi.kobo.com/v1/library/tags/{TagId}",
"review": "https://storeapi.kobo.com/v1/products/reviews/{ReviewId}",
"review_sentiment": "https://storeapi.kobo.com/v1/products/reviews/{ReviewId}/sentiment/{Sentiment}",
"shelfie_recommendations": "https://storeapi.kobo.com/v1/user/recommendations/shelfie",
"sign_in_page": "https://auth.kobobooks.com/ActivateOnWeb",
"social_authorization_host": "https://social.kobobooks.com:8443",
"social_host": "https://social.kobobooks.com",
"store_home": "www.kobo.com/{region}/{language}",
"store_host": "www.kobo.com",
"store_newreleases": "https://www.kobo.com/{region}/{language}/List/new-releases/961XUjtsU0qxkFItWOutGA",
"store_search": "https://www.kobo.com/{region}/{language}/Search?Query={query}",
"store_top50": "https://www.kobo.com/{region}/{language}/ebooks/Top",
"subs_landing_page": "https://www.kobo.com/{region}/{language}/plus",
"subs_management_page": "https://www.kobo.com/{region}/{language}/account/subscriptions",
"subs_purchase_buy_templated": "https://www.kobo.com/{region}/{language}/Checkoutoption/{ProductId}/{TierId}",
"subscription_publisher_price_page": "https://www.kobo.com/{region}/{language}/subscriptionpublisherprice",
"tag_items": "https://storeapi.kobo.com/v1/library/tags/{TagId}/Items",
"tags": "https://storeapi.kobo.com/v1/library/tags",
"taste_profile": "https://storeapi.kobo.com/v1/products/tasteprofile",
"terms_of_sale_page": "https://authorize.kobo.com/{region}/{language}/terms/termsofsale",
"update_accessibility_to_preview": "https://storeapi.kobo.com/v1/library/{EntitlementIds}/preview",
"use_one_store": "True",
"user_loyalty_benefits": "https://storeapi.kobo.com/v1/user/loyalty/benefits",
"user_platform": "https://storeapi.kobo.com/v1/user/platform",
"user_profile": "https://storeapi.kobo.com/v1/user/profile",
"user_ratings": "https://storeapi.kobo.com/v1/user/ratings",
"user_recommendations": "https://storeapi.kobo.com/v1/user/recommendations",
"user_reviews": "https://storeapi.kobo.com/v1/user/reviews",
"user_wishlist": "https://storeapi.kobo.com/v1/user/wishlist",
"userguide_host": "https://ereaderfiles.kobo.com",
"wishlist_page": "https://www.kobo.com/{region}/{language}/account/wishlist"
}
""".trimIndent(),
)
}
}

View file

@ -0,0 +1,66 @@
package org.gotson.komga.infrastructure.kobo
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.servlet.http.HttpServletRequest
import org.gotson.komga.domain.model.KomgaSyncToken
import org.gotson.komga.infrastructure.kobo.KoboHeaders.X_KOBO_SYNCTOKEN
import org.springframework.stereotype.Component
import java.util.Base64
private val logger = KotlinLogging.logger {}
private const val KOMGA_TOKEN_PREFIX = "KOMGA."
@Component
class KomgaSyncTokenGenerator(
private val objectMapper: ObjectMapper,
) {
private val base64Encoder by lazy { Base64.getEncoder().withoutPadding() }
private val base64Decoder by lazy { Base64.getDecoder() }
/**
* Convert any SyncToken to a [KomgaSyncToken].
* The input SyncToken type depends on the String format:
* - the official Kobo store token is of the form `base64.base64`
* - the Calibre Web token is a single base64 string
* - the Komga token is a base64 string prefixed by `KOMGA.`
*/
fun fromBase64(base64Token: String): KomgaSyncToken {
try {
// check for a Komga token
if (base64Token.startsWith(KOMGA_TOKEN_PREFIX)) {
return objectMapper.readValue(base64Decoder.decode(base64Token.removePrefix(KOMGA_TOKEN_PREFIX)))
}
// check for a Calibre Web token
if (!base64Token.contains('.')) {
try {
val json = objectMapper.readTree(base64Decoder.decode(base64Token))
val koboToken = json.get("data").get("raw_kobo_store_token").asText()
return KomgaSyncToken(rawKoboSyncToken = koboToken)
} catch (e: Exception) {
logger.warn { "Failed to parse potential CalibreWeb token" }
}
}
// check for a Kobo store token
if (base64Token.contains('.')) {
return KomgaSyncToken(rawKoboSyncToken = base64Token)
}
} catch (_: Exception) {
}
// in last resort return a default token
return KomgaSyncToken()
}
fun toBase64(token: KomgaSyncToken): String =
KOMGA_TOKEN_PREFIX + base64Encoder.encodeToString(objectMapper.writeValueAsString(token).toByteArray())
fun fromRequestHeaders(request: HttpServletRequest): KomgaSyncToken? {
val syncTokenB64 = request.getHeader(X_KOBO_SYNCTOKEN)
return if (syncTokenB64 != null) fromBase64(syncTokenB64) else null
}
}

View file

@ -16,6 +16,7 @@ class EtagFilterConfiguration {
PathPatternParser.defaultInstance.parse("/opds/v1.2/books/*/file/**"),
PathPatternParser.defaultInstance.parse("/api/v1/readlists/*/file/**"),
PathPatternParser.defaultInstance.parse("/api/v1/series/*/file/**"),
PathPatternParser.defaultInstance.parse("/kobo/*/v1/books/*/file/**"),
)
@Bean
@ -31,6 +32,7 @@ class EtagFilterConfiguration {
it.addUrlPatterns(
"/api/*",
"/opds/*",
"/kobo/*",
)
it.setName("etagFilter")
}

View file

@ -0,0 +1,20 @@
package org.gotson.komga.infrastructure.web
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.filter.CommonsRequestLoggingFilter
@Configuration
class RequestLoggingFilterConfig {
@ConditionalOnProperty(value = ["logging.level.org.springframework.web.filter.CommonsRequestLoggingFilter"], havingValue = "debug")
@Bean
fun logFilter() =
CommonsRequestLoggingFilter().apply {
setIncludeQueryString(true)
setIncludePayload(true)
setMaxPayloadLength(10000)
setIncludeHeaders(true)
setAfterMessagePrefix("REQUEST DATA: ")
}
}

View file

@ -1,8 +1,11 @@
package org.gotson.komga.infrastructure.web
import jakarta.servlet.http.HttpServletRequest
import org.springframework.http.CacheControl
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletRequestAttributes
import java.net.URL
import java.nio.file.Paths
import java.util.concurrent.TimeUnit
@ -34,3 +37,5 @@ fun getMediaTypeOrDefault(mediaTypeString: String?): MediaType {
}
return MediaType.APPLICATION_OCTET_STREAM
}
fun getCurrentRequest(): HttpServletRequest = (RequestContextHolder.getRequestAttributes() as ServletRequestAttributes?)?.request ?: throw IllegalStateException("Could not get current request")

View file

@ -321,6 +321,11 @@ class CommonBookController(
fun getBookFile(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String,
): ResponseEntity<StreamingResponseBody> = getBookFileInternal(principal, bookId)
fun getBookFileInternal(
principal: KomgaPrincipal,
bookId: String,
): ResponseEntity<StreamingResponseBody> =
bookRepository.findByIdOrNull(bookId)?.let { book ->
contentRestrictionChecker.checkContentRestriction(principal.user, book)

View file

@ -0,0 +1,599 @@
package org.gotson.komga.interfaces.api.kobo
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
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.image.ImageConverter
import org.gotson.komga.infrastructure.image.ImageType
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
import org.gotson.komga.infrastructure.kobo.KoboProxy
import org.gotson.komga.infrastructure.kobo.KomgaSyncTokenGenerator
import org.gotson.komga.infrastructure.security.KomgaPrincipal
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.ChangedReadingStateDto
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.ReadingStateStateUpdateDto
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.WrappedReadingStateDto
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
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
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
import org.springframework.web.bind.annotation.RequestMethod
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
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 {}
/**
* The following documentation is coming from the awesome work from [Calibre-web](https://github.com/gotson/calibre-web/blob/14b578dd3a15bd371102d5b9828da830e59b4557/cps/kobo_auth.py).
*
* **Log-in**
*
* When first booting a Kobo device the user must sign into a Kobo (or affiliate) account.
* Upon successful sign-in, the user is redirected to
* https://auth.kobobooks.com/CrossDomainSignIn?id=<some id>
* which serves the following response:
*
* ```html
* <script type='text/javascript'>
* location.href='kobo://UserAuthenticated?userId=<redacted>&userKey<redacted>&email=<redacted>&returnUrl=https%3a%2f%2fwww.kobo.com';
* </script>
* ```
*
* And triggers the insertion of a userKey into the device's User table.
*
* Together, the device's DeviceId and UserKey act as an *irrevocable* authentication
* token to most (if not all) Kobo APIs. In fact, in most cases only the UserKey is
* required to authorize the API call.
*
* Changing Kobo password *does not* invalidate user keys! This is apparently a known
* issue for a few years now https://www.mobileread.com/forums/showpost.php?p=3476851&postcount=13
* (although this poster hypothesised that Kobo could blacklist a DeviceId, many endpoints
* will still grant access given the userkey.)
*
* **Official Kobo Store Api authorization**
*
* * For most of the endpoints we care about (sync, metadata, tags, etc), the userKey is
* passed in the x-kobo-userkey header, and is sufficient to authorize the API call.
* * Some endpoints (e.g: AnnotationService) instead make use of Bearer tokens pass through
* an authorization header. To get a BearerToken, the device makes a POST request to the
* /v1/auth/device endpoint with the secret UserKey and the device's DeviceId.
* * The book download endpoint passes an auth token as a URL param instead of a header.
*
* **Komga implementation**
*
* To authenticate the user, an API key is added to the Komga URL when setting up the api_store
* setting on the device.
* Thus, every request from the device to the api_store will hit Komga with the
* API key in the url (e.g: https://mylibrary.com/kobo/<api_key>/v1/library/sync).
*
* In addition, once authenticated a session cookie is set on response, which will
* be sent back for the duration of the session to authorize subsequent API calls
* and avoid having to lookup the API key in database.
*/
@RestController
@RequestMapping(value = ["/kobo/{authToken}/"], produces = ["application/json; charset=utf-8"])
class KoboController(
private val koboProxy: KoboProxy,
private val syncPointLifecycle: SyncPointLifecycle,
private val syncPointRepository: SyncPointRepository,
private val komgaSyncTokenGenerator: KomgaSyncTokenGenerator,
private val komgaProperties: KomgaProperties,
private val koboDtoRepository: KoboDtoRepository,
private val mapper: ObjectMapper,
private val commonBookController: CommonBookController,
private val bookLifecycle: BookLifecycle,
private val bookRepository: BookRepository,
private val readProgressRepository: ReadProgressRepository,
private val imageConverter: ImageConverter,
) {
@GetMapping("ping")
fun ping() = "pong"
@GetMapping("v1/initialization")
fun initialization(
@PathVariable authToken: String,
): ResponseEntity<ResourcesDto> {
val resources =
try {
koboProxy.proxyCurrentRequest().body?.get("Resources")
} catch (e: Exception) {
logger.warn { "Failed to get response from Kobo /v1/initialization, fallback to noproxy" }
null
} ?: koboProxy.nativeKoboResources
with(resources as ObjectNode) {
put("image_host", ServletUriComponentsBuilder.fromCurrentContextPath().toUriString())
put("image_url_template", ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("kobo", authToken, "v1", "books", "{ImageId}", "thumbnail", "{Width}", "{Height}", "false", "image.jpg").build().toUriString())
put("image_url_quality_template", ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("kobo", authToken, "v1", "books", "{ImageId}", "thumbnail", "{Width}", "{Height}", "{Quality}", "{IsGreyscale}", "image.jpg").build().toUriString())
}
return ResponseEntity.ok()
.header("x-kobo-apitoken", "e30=")
.body(ResourcesDto(resources))
}
/**
* @return an [AuthDto]
*/
@PostMapping("v1/auth/device")
fun authDevice(
@RequestBody body: JsonNode,
): Any {
try {
return koboProxy.proxyCurrentRequest(body)
} catch (e: Exception) {
logger.warn { "Failed to get response from Kobo /v1/auth/device, fallback to noproxy" }
}
/**
* Komga does not use the /v1/auth/device API call for authentication/authorization.
* Return dummy data to keep the device happy.
*/
return AuthDto(
accessToken = RandomStringUtils.randomAlphanumeric(24),
refreshToken = RandomStringUtils.randomAlphanumeric(24),
trackingId = UUID.randomUUID().toString(),
userKey = body.get("UserKey")?.asText() ?: "",
)
}
// @RequestMapping(value = ["/v1/analytics/gettests"], method = [RequestMethod.GET, RequestMethod.POST])
fun analyticsGetTests(
@RequestHeader(name = X_KOBO_USERKEY, required = false) userKey: String?,
) = TestsDto(
result = "Success",
testKey = userKey ?: "",
)
/**
* @return an array of [SyncResultDto]
*/
@GetMapping("v1/library/sync")
fun syncLibrary(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable authToken: String,
): ResponseEntity<Collection<Any>> {
val syncTokenReceived = komgaSyncTokenGenerator.fromRequestHeaders(getCurrentRequest()) ?: KomgaSyncToken()
// find the ongoing sync point, else create one
val toSyncPoint =
getSyncPointVerified(syncTokenReceived.ongoingSyncPointId, principal.user.id)
?: syncPointLifecycle.createSyncPoint(principal.user, principal.apiKey?.id, null) // for now we sync all libraries
// find the last successful sync, if any
val fromSyncPoint = getSyncPointVerified(syncTokenReceived.lastSuccessfulSyncPointId, principal.user.id)
logger.debug { "Library sync from SyncPoint $fromSyncPoint, to SyncPoint: $toSyncPoint" }
var shouldContinueSync: Boolean
val syncResultKomga: Collection<SyncResultDto> =
if (fromSyncPoint != null) {
// find books added/changed/removed and map to DTO
var maxRemainingCount = komgaProperties.kobo.syncItemLimit
val booksAdded =
syncPointLifecycle.takeBooksAdded(fromSyncPoint.id, toSyncPoint.id, Pageable.ofSize(maxRemainingCount)).also {
maxRemainingCount -= it.numberOfElements
shouldContinueSync = it.hasNext()
}
val booksChanged =
if (booksAdded.isLast && maxRemainingCount > 0)
syncPointLifecycle.takeBooksChanged(fromSyncPoint.id, toSyncPoint.id, Pageable.ofSize(maxRemainingCount)).also {
maxRemainingCount -= it.numberOfElements
shouldContinueSync = shouldContinueSync || it.hasNext()
}
else
Page.empty()
val booksRemoved =
if (booksChanged.isLast && maxRemainingCount > 0)
syncPointLifecycle.takeBooksRemoved(fromSyncPoint.id, toSyncPoint.id, Pageable.ofSize(maxRemainingCount)).also {
maxRemainingCount -= it.numberOfElements
shouldContinueSync = shouldContinueSync || it.hasNext()
}
else
Page.empty()
val changedReadingState =
if (booksRemoved.isLast && maxRemainingCount > 0)
syncPointLifecycle.takeBooksReadProgressChanged(fromSyncPoint.id, toSyncPoint.id, Pageable.ofSize(maxRemainingCount)).also {
maxRemainingCount -= it.numberOfElements
shouldContinueSync = shouldContinueSync || it.hasNext()
}
else
Page.empty()
logger.debug { "Library sync: ${booksAdded.numberOfElements} books added, ${booksChanged.numberOfElements} books changed, ${booksRemoved.numberOfElements} books removed, ${changedReadingState.numberOfElements} books with changed reading state" }
val metadata = koboDtoRepository.findBookMetadataByIds((booksAdded.content + booksChanged.content).map { it.bookId }, getDownloadUrlBuilder(authToken)).associateBy { it.entitlementId }
val readProgress = readProgressRepository.findAllByBookIdsAndUserId((booksAdded.content + booksChanged.content + changedReadingState.content).map { it.bookId }, principal.user.id).associateBy { it.bookId }
buildList {
addAll(
booksAdded.content.map {
NewEntitlementDto(
BookEntitlementContainerDto(
bookEntitlement = it.toBookEntitlementDto(false),
bookMetadata = metadata[it.bookId]!!,
readingState = readProgress[it.bookId]?.toDto() ?: getEmptyReadProgressForBook(it.bookId, it.createdDate),
),
)
},
)
addAll(
booksChanged.content.map {
ChangedEntitlementDto(
BookEntitlementContainerDto(
bookEntitlement = it.toBookEntitlementDto(false),
bookMetadata = metadata[it.bookId]!!,
readingState = readProgress[it.bookId]?.toDto() ?: getEmptyReadProgressForBook(it.bookId, it.createdDate),
),
)
},
)
addAll(
booksRemoved.content.map {
ChangedEntitlementDto(
BookEntitlementContainerDto(
bookEntitlement = it.toBookEntitlementDto(true),
bookMetadata = getMetadataForRemovedBook(it.bookId),
),
)
},
)
addAll(
// changed books are also passed as changed reading state because Kobo does not process ChangedEntitlement even if it contains a ReadingState
(booksChanged.content + changedReadingState.content).mapNotNull { book ->
readProgress[book.bookId]?.let { it ->
ChangedReadingStateDto(
WrappedReadingStateDto(
it.toDto(),
),
)
}
},
)
}
} else {
// no starting point, sync everything
val books = syncPointLifecycle.takeBooks(toSyncPoint.id, Pageable.ofSize(komgaProperties.kobo.syncItemLimit))
shouldContinueSync = books.hasNext()
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),
),
)
}
}
// merge Kobo store sync response, we only trigger this once all Komga updates have been processed (shouldContinueSync == false)
val (syncResultMerged, syncTokenMerged, shouldContinueSyncMerged) =
if (!shouldContinueSync && koboProxy.isEnabled()) {
try {
val koboStoreResponse = koboProxy.proxyCurrentRequest(includeSyncToken = true)
val syncResultsKobo = koboStoreResponse.body?.let { mapper.treeToValue<Collection<Any>>(it) } ?: emptyList()
val syncTokenKobo = koboStoreResponse.headers[X_KOBO_SYNCTOKEN]?.firstOrNull()?.let { komgaSyncTokenGenerator.fromBase64(it) }
val shouldContinueSyncKobo = koboStoreResponse.headers[X_KOBO_SYNC]?.firstOrNull()?.lowercase() == "continue"
Triple(syncResultKomga + syncResultsKobo, syncTokenKobo ?: syncTokenReceived, shouldContinueSyncKobo)
} catch (e: Exception) {
logger.error(e) { "Kobo sync endpoint failure" }
Triple(syncResultKomga, syncTokenReceived, false)
}
} else {
Triple(syncResultKomga, syncTokenReceived, shouldContinueSync)
}
// update synctoken to send back to Kobo device
val syncTokenUpdated =
if (shouldContinueSyncMerged) {
syncTokenMerged.copy(ongoingSyncPointId = toSyncPoint.id)
} else {
// cleanup old syncpoint if it exists
fromSyncPoint?.let { syncPointRepository.deleteOne(it.id) }
syncTokenMerged.copy(ongoingSyncPointId = null, lastSuccessfulSyncPointId = toSyncPoint.id)
}
return ResponseEntity
.ok()
.headers {
if (shouldContinueSyncMerged) it.set(X_KOBO_SYNC, "continue")
it.set(X_KOBO_SYNCTOKEN, komgaSyncTokenGenerator.toBase64(syncTokenUpdated))
}
.body(syncResultMerged)
}
/**
* @return an array of [KoboBookMetadataDto]
*/
@GetMapping("/v1/library/{bookId}/metadata")
fun getBookMetadata(
@PathVariable authToken: String,
@PathVariable bookId: String,
): ResponseEntity<*> =
if (!bookRepository.existsById(bookId) && koboProxy.isEnabled())
koboProxy.proxyCurrentRequest()
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 body: ReadingStateStateUpdateDto,
@RequestHeader(name = X_KOBO_DEVICEID, required = false) koboDeviceId: String = "unknown",
): ResponseEntity<*> {
val book =
bookRepository.findByIdOrNull(bookId)
?: if (koboProxy.isEnabled())
return koboProxy.proxyCurrentRequest(body)
else
throw ResponseStatusException(HttpStatus.NOT_FOUND)
val koboUpdate = body.readingStates.firstOrNull() ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST)
if (koboUpdate.currentBookmark.location == null || koboUpdate.currentBookmark.contentSourceProgressPercent == null) throw ResponseStatusException(HttpStatus.BAD_REQUEST)
// convert the Kobo update request to an R2Progression
val r2Progression =
R2Progression(
modified = koboUpdate.lastModified,
device =
R2Device(
id = principal.apiKey?.id ?: "unknown",
name = principal.apiKey?.comment ?: "unknown",
),
locator =
R2Locator(
href = koboUpdate.currentBookmark.location.source,
// assume default, will be overwritten by the correct type when saved
type = "application/xhtml+xml",
locations =
R2Locator.Location(
progression = koboUpdate.currentBookmark.contentSourceProgressPercent / 100,
),
),
)
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) {
logger.error(e) { "Could not update progression" }
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],
)
@PreAuthorize("hasRole('$ROLE_FILE_DOWNLOAD')")
fun getBookFile(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String,
): ResponseEntity<StreamingResponseBody> = commonBookController.getBookFileInternal(principal, bookId)
@GetMapping(
value = [
"v1/books/{bookId}/thumbnail/{width}/{height}/{isGreyScale}/image.jpg",
"v1/books/{bookId}/thumbnail/{width}/{height}/{quality}/{isGreyScale}/image.jpg",
],
produces = [MediaType.IMAGE_JPEG_VALUE],
)
fun getBookCover(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String,
@PathVariable width: String?,
@PathVariable height: String?,
@PathVariable quality: String?,
@PathVariable isGreyScale: String?,
): ResponseEntity<Any> =
if (!bookRepository.existsById(bookId) && koboProxy.isEnabled()) {
ResponseEntity
.status(HttpStatus.TEMPORARY_REDIRECT)
.location(UriComponentsBuilder.fromHttpUrl(koboProxy.imageHostUrl).buildAndExpand(bookId, width, height).toUri())
.build()
} else {
val poster = bookLifecycle.getThumbnailBytes(bookId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
val posterBytes =
if (poster.mediaType != ImageType.JPEG.mediaType)
imageConverter.convertImage(poster.bytes, ImageType.JPEG.imageIOFormat)
else
poster.bytes
ResponseEntity.ok(posterBytes)
}
@RequestMapping(
value = ["{*path}"],
method = [RequestMethod.GET, RequestMethod.PUT, RequestMethod.POST, RequestMethod.DELETE, RequestMethod.PATCH],
)
fun catchAll(
@RequestBody body: Any?,
): ResponseEntity<JsonNode> {
return if (koboProxy.isEnabled())
koboProxy.proxyCurrentRequest(body)
else
ResponseEntity.ok().body(mapper.createObjectNode())
}
private fun getDownloadUrlBuilder(token: String): UriBuilder =
ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("kobo", token, "v1", "books", "{bookId}", "file", "epub")
/**
* Retrieve a SyncPoint by ID, and verifies it belongs to the same userId
*/
private fun getSyncPointVerified(
syncPointId: String?,
userId: String,
): SyncPoint? {
if (syncPointId != null) {
val syncPoint = syncPointRepository.findByIdOrNull(syncPointId)
// verify that the SyncPoint is owned by the user
if (syncPoint?.userId == userId) return syncPoint
}
return null
}
private fun getMetadataForRemovedBook(bookId: String) =
KoboBookMetadataDto(
coverImageId = bookId,
crossRevisionId = bookId,
entitlementId = bookId,
revisionId = bookId,
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,
),
)
}
}

View file

@ -0,0 +1,12 @@
package org.gotson.komga.interfaces.api.kobo.dto
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.databind.annotation.JsonNaming
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class)
@JsonInclude(JsonInclude.Include.NON_NULL)
data class AmountDto(
val currencyCode: String? = null,
val totalAmount: Int,
)

View file

@ -0,0 +1,13 @@
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 AuthDto(
val accessToken: String,
val refreshToken: String,
val tokenType: String = "Bearer",
val trackingId: String,
val userKey: String,
)

View file

@ -0,0 +1,11 @@
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 BookEntitlementContainerDto(
val bookEntitlement: BookEntitlementDto,
val bookMetadata: KoboBookMetadataDto,
val readingState: ReadingStateDto? = null,
)

View file

@ -0,0 +1,37 @@
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.SyncPoint
import java.time.ZoneId
import java.time.ZonedDateTime
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class)
data class BookEntitlementDto(
val accessibility: String = "Full",
val activePeriod: PeriodDto,
val created: ZonedDateTime,
val crossRevisionId: String,
val id: String,
val isHiddenFromArchive: Boolean = false,
val isLocked: Boolean = false,
/**
* True if the book has been deleted or is not available
*/
val isRemoved: Boolean,
val lastModified: ZonedDateTime,
val originCategory: String = "Imported",
val revisionId: String,
val status: String = "Active",
)
fun SyncPoint.Book.toBookEntitlementDto(isRemoved: Boolean) =
BookEntitlementDto(
activePeriod = ZonedDateTime.now(ZoneId.of("Z")).toPeriodDto(),
created = createdDate,
crossRevisionId = bookId,
revisionId = bookId,
id = bookId,
isRemoved = isRemoved,
lastModified = fileLastModified,
)

View file

@ -0,0 +1,23 @@
package org.gotson.komga.interfaces.api.kobo.dto
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.databind.annotation.JsonNaming
import java.time.ZonedDateTime
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class)
@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,
)

View file

@ -0,0 +1,9 @@
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 ContributorDto(
val name: String,
)

View file

@ -0,0 +1,15 @@
package org.gotson.komga.interfaces.api.kobo.dto
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.databind.annotation.JsonNaming
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class)
@JsonInclude(JsonInclude.Include.NON_NULL)
data class DownloadUrlDto(
val drmType: String = "None",
val format: FormatDto,
val size: Long,
val platform: String = "Generic",
val url: String,
)

View file

@ -0,0 +1,8 @@
package org.gotson.komga.interfaces.api.kobo.dto
enum class FormatDto {
EPUB3FL,
EPUB,
EPUB3,
KEPUB,
}

View file

@ -0,0 +1,43 @@
package org.gotson.komga.interfaces.api.kobo.dto
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.databind.annotation.JsonNaming
import java.time.ZonedDateTime
const val DUMMY_ID = "00000000-0000-0000-0000-000000000001"
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class)
@JsonInclude(JsonInclude.Include.NON_NULL)
data class KoboBookMetadataDto(
val categories: List<String> = listOf(DUMMY_ID),
val contributorRoles: List<ContributorDto> = emptyList(),
val contributors: List<String> = emptyList(),
val coverImageId: String,
val crossRevisionId: String,
val currentDisplayPrice: AmountDto = AmountDto("USD", 0),
val currentLoveDisplayPrice: AmountDto = AmountDto(totalAmount = 0),
val description: String? = null,
val downloadUrls: List<DownloadUrlDto> = emptyList(),
val entitlementId: String,
val externalIds: List<String> = emptyList(),
val genre: String = DUMMY_ID,
val isEligibleForKoboLove: Boolean = false,
val isInternetArchive: Boolean = false,
val isPreOrder: Boolean = false,
val isSocialEnabled: Boolean = true,
val isbn: String? = null,
/**
* 2-letter code
*/
val language: String = "en",
val phoneticPronunciations: Map<String, String> = emptyMap(),
val publicationDate: ZonedDateTime? = null,
val publisher: PublisherDto? = null,
val revisionId: String,
val series: KoboSeriesDto? = null,
val slug: String? = null,
val subTitle: String? = null,
val title: String,
val workId: String,
)

View file

@ -0,0 +1,12 @@
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 KoboSeriesDto(
val id: String,
val name: String,
val number: String,
val numberFloat: Float,
)

View file

@ -0,0 +1,20 @@
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 LocationDto(
/**
* 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,
)

View file

@ -0,0 +1,12 @@
package org.gotson.komga.interfaces.api.kobo.dto
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.databind.annotation.JsonNaming
import java.time.ZonedDateTime
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class)
data class PeriodDto(
val from: ZonedDateTime,
)
fun ZonedDateTime.toPeriodDto() = PeriodDto(this)

View file

@ -0,0 +1,10 @@
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 PublisherDto(
val imprint: String = "",
val name: String,
)

View file

@ -0,0 +1,56 @@
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)
data class ReadingStateDto(
val created: ZonedDateTime? = null,
val currentBookmark: BookmarkDto,
val entitlementId: String,
val lastModified: ZonedDateTime,
/**
* From CW: apparently always equals to lastModified
*/
val priorityTimestamp: ZonedDateTime? = null,
val statistics: StatisticsDto,
val statusInfo: StatusInfoDto,
)
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class)
data class WrappedReadingStateDto(
val readingState: ReadingStateDto,
)
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?.times(100),
contentSourceProgressPercent = this.locator?.locations?.progression?.times(100),
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,
),
)

View file

@ -0,0 +1,9 @@
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 ReadingStateStateUpdateDto(
val readingStates: Collection<ReadingStateDto> = emptyList(),
)

View file

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

View file

@ -0,0 +1,10 @@
package org.gotson.komga.interfaces.api.kobo.dto
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.databind.annotation.JsonNaming
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class)
data class ResourcesDto(
val resources: JsonNode,
)

View file

@ -0,0 +1,18 @@
package org.gotson.komga.interfaces.api.kobo.dto
import com.fasterxml.jackson.annotation.JsonProperty
enum class ResultDto {
@JsonProperty("Success")
SUCCESS,
// Not sure what Kobo accepts exactly, so I made up my own
@JsonProperty("Failure")
FAILURE,
@JsonProperty("Ignored")
IGNORED,
;
fun wrapped() = WrappedResultDto(this)
}

View file

@ -0,0 +1,14 @@
package org.gotson.komga.interfaces.api.kobo.dto
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.databind.annotation.JsonNaming
import java.time.ZonedDateTime
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class)
@JsonInclude(JsonInclude.Include.NON_NULL)
data class StatisticsDto(
val lastModified: ZonedDateTime,
val remainingTimeMinutes: Int? = null,
val spentReadingMinutes: Int? = null,
)

View file

@ -0,0 +1,17 @@
package org.gotson.komga.interfaces.api.kobo.dto
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.databind.annotation.JsonNaming
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class)
enum class StatusDto {
@JsonProperty("ReadyToRead")
READY_TO_READ,
@JsonProperty("Finished")
FINISHED,
@JsonProperty("Reading")
READING,
}

View file

@ -0,0 +1,16 @@
package org.gotson.komga.interfaces.api.kobo.dto
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.databind.annotation.JsonNaming
import java.time.ZonedDateTime
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class)
@JsonInclude(JsonInclude.Include.NON_NULL)
data class StatusInfoDto(
val lastModified: ZonedDateTime,
val status: StatusDto,
val timesStartedReading: Int? = null,
val lastTimeFinished: ZonedDateTime? = null,
val lastTimeStartedReading: ZonedDateTime? = null,
)

View file

@ -0,0 +1,31 @@
package org.gotson.komga.interfaces.api.kobo.dto
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.databind.annotation.JsonNaming
interface SyncResultDto
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class)
data class NewEntitlementDto(
val newEntitlement: BookEntitlementContainerDto,
) : SyncResultDto
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class)
data class ChangedEntitlementDto(
val changedEntitlement: BookEntitlementContainerDto,
) : SyncResultDto
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class)
data class NewTagDto(
val newTag: TagDto,
) : SyncResultDto
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class)
data class ChangedTagDto(
val changedTag: TagDto,
) : SyncResultDto
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class)
data class ChangedReadingStateDto(
val changedReadingState: WrappedReadingStateDto,
) : SyncResultDto

View file

@ -0,0 +1,15 @@
package org.gotson.komga.interfaces.api.kobo.dto
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.databind.annotation.JsonNaming
import java.time.ZonedDateTime
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class)
data class TagDto(
val id: String,
val created: ZonedDateTime,
val lastModified: ZonedDateTime,
val name: String,
val type: TagTypeDto,
val items: List<TagItemDto>? = null,
)

View file

@ -0,0 +1,10 @@
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 TagItemDto(
val revisionId: String,
val type: String = "ProductRevisionTagItem",
)

View file

@ -0,0 +1,10 @@
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 TagTypeDto {
SYSTEM_TAG,
USER_TAG,
}

View file

@ -0,0 +1,11 @@
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 TestsDto(
val result: String,
val testKey: String,
val tests: Map<String, String> = emptyMap(),
)

View file

@ -0,0 +1,11 @@
package org.gotson.komga.interfaces.api.kobo.persistence
import org.gotson.komga.interfaces.api.kobo.dto.KoboBookMetadataDto
import org.springframework.web.util.UriBuilder
interface KoboDtoRepository {
fun findBookMetadataByIds(
bookIds: Collection<String>,
downloadUriBuilder: UriBuilder,
): Collection<KoboBookMetadataDto>
}

View file

@ -40,6 +40,7 @@ class SettingsController(
komgaSettingsProvider.taskPoolSize,
SettingMultiSource(configServerPort, komgaSettingsProvider.serverPort, serverSettings.effectiveServerPort),
SettingMultiSource(configServerContextPath, komgaSettingsProvider.serverContextPath, serverSettings.effectiveServletContextPath),
komgaSettingsProvider.koboProxy,
)
@PatchMapping
@ -57,5 +58,7 @@ class SettingsController(
if (newSettings.isSet("serverPort")) komgaSettingsProvider.serverPort = newSettings.serverPort
if (newSettings.isSet("serverContextPath")) komgaSettingsProvider.serverContextPath = newSettings.serverContextPath
newSettings.koboProxy?.let { komgaSettingsProvider.koboProxy = it }
}
}

View file

@ -0,0 +1,30 @@
package org.gotson.komga.interfaces.api.rest
import org.gotson.komga.domain.persistence.SyncPointRepository
import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("api/v1/syncpoints", produces = [MediaType.APPLICATION_JSON_VALUE])
class SyncPointController(
private val syncPointRepository: SyncPointRepository,
) {
@DeleteMapping("me")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun deleteMySyncPointsByApiKey(
@AuthenticationPrincipal principal: KomgaPrincipal,
@RequestParam(required = false, name = "key_id") keyIds: Collection<String>?,
) {
if (keyIds.isNullOrEmpty())
syncPointRepository.deleteByUserId(principal.user.id)
else
syncPointRepository.deleteByUserIdAndApiKeyIds(principal.user.id, keyIds)
}
}

View file

@ -8,6 +8,7 @@ data class SettingsDto(
val taskPoolSize: Int,
val serverPort: SettingMultiSource<Int?>,
val serverContextPath: SettingMultiSource<String?>,
val koboProxy: Boolean,
)
data class SettingMultiSource<T>(

View file

@ -36,4 +36,6 @@ class SettingsUpdateDto {
by Delegates.observable(null) { prop, _, _ ->
isSet[prop.name] = true
}
var koboProxy: Boolean? = null
}

View file

@ -7,6 +7,7 @@ import org.gotson.komga.domain.model.AllowExclude
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD
import org.gotson.komga.domain.model.ROLE_KOBO_SYNC
import org.gotson.komga.domain.model.ROLE_PAGE_STREAMING
import org.gotson.komga.infrastructure.security.KomgaPrincipal
@ -54,6 +55,7 @@ data class UserCreationDto(
roleAdmin = roles.contains(ROLE_ADMIN),
roleFileDownload = roles.contains(ROLE_FILE_DOWNLOAD),
rolePageStreaming = roles.contains(ROLE_PAGE_STREAMING),
roleKoboSync = roles.contains(ROLE_KOBO_SYNC),
)
}

View file

@ -39,7 +39,7 @@ class InitialUsersDevConfiguration {
@Bean
fun initialUsers(): List<KomgaUser> =
listOf(
KomgaUser("admin@example.org", "admin", roleAdmin = true),
KomgaUser("admin@example.org", "admin", roleAdmin = true, roleKoboSync = true),
KomgaUser("user@example.org", "user", roleAdmin = false),
)
}

View file

@ -56,11 +56,33 @@ fun String.stripAccents(): String = StringUtils.stripAccents(this)
fun LocalDate.toDate(): Date = Date.from(this.atStartOfDay(ZoneId.of("Z")).toInstant())
/**
* Converts a LocalDateTime (current timezone) to a LocalDateTime (UTC)
* Warning: this is not idempotent
*/
fun LocalDateTime.toUTC(): LocalDateTime =
atZone(ZoneId.systemDefault()).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime()
/**
* Converts a LocalDateTime (current timezone) to a ZonedDateTime
*/
fun LocalDateTime.toUTCZoned(): ZonedDateTime =
atZone(ZoneId.systemDefault()).withZoneSameInstant(ZoneOffset.UTC)
/**
* Converts a LocalDateTime (UTC) to a ZonedDateTime
*/
fun LocalDateTime.toZonedDateTime(): ZonedDateTime =
this.atZone(ZoneId.of("Z")).withZoneSameInstant(ZoneId.systemDefault())
/**
* Converts a LocalDateTime (UTC) to a LocalDateTime (current timezone)
*/
fun LocalDateTime.toCurrentTimeZone(): LocalDateTime =
this.atZone(ZoneId.of("Z")).withZoneSameInstant(ZoneId.systemDefault()).toLocalDateTime()
fun Iterable<String>.contains(
s: String,
ignoreCase: Boolean = false,
): Boolean =
any { it.equals(s, ignoreCase) }

View file

@ -11,12 +11,15 @@ logging:
level:
org.apache.activemq.audit.message: WARN
org.gotson.komga: DEBUG
# org.springframework.web.filter.CommonsRequestLoggingFilter: DEBUG
# reactor.netty.http.client: DEBUG
# org.jooq: DEBUG
# org.jooq.tools.LoggerListener: DEBUG
# web: DEBUG
# com.zaxxer.hikari: DEBUG
# org.springframework.boot.autoconfigure: DEBUG
# org.springframework.security.web.FilterChainProxy: DEBUG
# org.springframework.security.web.authentication.rememberme: DEBUG
logback:
rollingpolicy:
max-history: 1

View file

@ -0,0 +1,272 @@
package org.gotson.komga.domain.service
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.within
import org.gotson.komga.domain.model.AgeRestriction
import org.gotson.komga.domain.model.AllowExclude
import org.gotson.komga.domain.model.ContentRestrictions
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.MediaType
import org.gotson.komga.domain.model.makeBook
import org.gotson.komga.domain.model.makeLibrary
import org.gotson.komga.domain.model.makeSeries
import org.gotson.komga.domain.persistence.BookMetadataRepository
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.KomgaUserRepository
import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.persistence.MediaRepository
import org.gotson.komga.domain.persistence.SeriesMetadataRepository
import org.gotson.komga.domain.persistence.SeriesRepository
import org.gotson.komga.domain.persistence.SyncPointRepository
import org.gotson.komga.language.toZonedDateTime
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
@SpringBootTest
class SyncPointLifecycleTest(
@Autowired private val syncPointLifecycle: SyncPointLifecycle,
@Autowired private val syncPointRepository: SyncPointRepository,
@Autowired private val libraryRepository: LibraryRepository,
@Autowired private val userRepository: KomgaUserRepository,
@Autowired private val bookRepository: BookRepository,
@Autowired private val bookMetadataRepository: BookMetadataRepository,
@Autowired private val seriesMetadataRepository: SeriesMetadataRepository,
@Autowired private val seriesRepository: SeriesRepository,
@Autowired private val seriesLifecycle: SeriesLifecycle,
@Autowired private val mediaRepository: MediaRepository,
) {
@Autowired
private lateinit var bookLifecycle: BookLifecycle
private val library1 = makeLibrary()
private val library2 = makeLibrary()
private val library3 = makeLibrary()
private val user1 =
KomgaUser(
"user1@example.org",
"",
false,
sharedLibrariesIds = setOf(library1.id, library2.id),
restrictions =
ContentRestrictions(
ageRestriction = AgeRestriction(18, AllowExclude.EXCLUDE),
labelsExclude = setOf("exclude"),
),
)
@BeforeAll
fun `setup library`() {
libraryRepository.insert(library1)
libraryRepository.insert(library2)
libraryRepository.insert(library3)
userRepository.insert(user1)
}
@AfterAll
fun teardown() {
libraryRepository.deleteAll()
syncPointRepository.deleteAll()
userRepository.deleteAll()
}
@AfterEach
fun `clear repository`() {
seriesLifecycle.deleteMany(seriesRepository.findAll())
}
@Test
fun `given user when creating syncpoint then all in-scope books are included`() {
// given
val bookValid = makeBook("valid", libraryId = library1.id).copy(fileHash = "hash", fileSize = 12, fileLastModified = LocalDateTime.now())
val bookExcludedByAge = makeBook("age restriction", libraryId = library1.id)
val bookExcludedByLabel = makeBook("label restriction", libraryId = library1.id)
val bookDeleted = makeBook("deleted", libraryId = library1.id).copy(deletedDate = LocalDateTime.now())
val bookNotReady = makeBook("media not ready", libraryId = library1.id)
val bookNotEpub = makeBook("not epub", libraryId = library1.id)
val bookOtherLibrary = makeBook("lib not in list", libraryId = library2.id)
val bookUnauthorizedLibrary = makeBook("unauthorized lib", libraryId = library3.id)
makeSeries(name = "series1", libraryId = library1.id).let { series ->
seriesLifecycle.createSeries(series).let { created ->
seriesLifecycle.addBooks(created, listOf(bookValid, bookDeleted, bookNotReady, bookNotEpub))
}
}
makeSeries(name = "series age restricted", libraryId = library1.id).let { series ->
seriesLifecycle.createSeries(series).let { created ->
seriesLifecycle.addBooks(created, listOf(bookExcludedByAge))
}
seriesMetadataRepository.findById(series.id).let { seriesMetadataRepository.update(it.copy(ageRating = 20)) }
}
makeSeries(name = "series label restricted", libraryId = library1.id).let { series ->
seriesLifecycle.createSeries(series).let { created ->
seriesLifecycle.addBooks(created, listOf(bookExcludedByLabel))
}
seriesMetadataRepository.findById(series.id).let { seriesMetadataRepository.update(it.copy(sharingLabels = setOf("exclude"))) }
}
makeSeries(name = "series2", libraryId = library2.id).let { series ->
seriesLifecycle.createSeries(series).let { created ->
seriesLifecycle.addBooks(created, listOf(bookOtherLibrary))
}
}
makeSeries(name = "series3", libraryId = library3.id).let { series ->
seriesLifecycle.createSeries(series).let { created ->
seriesLifecycle.addBooks(created, listOf(bookUnauthorizedLibrary))
}
}
bookRepository.findAll().forEach { mediaRepository.findById(it.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = MediaType.EPUB.type)) } }
mediaRepository.findById(bookNotReady.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.ERROR)) }
mediaRepository.findById(bookNotEpub.id).let { media -> mediaRepository.update(media.copy(mediaType = MediaType.ZIP.type)) }
// when
val syncPoint = syncPointLifecycle.createSyncPoint(user1, null, listOf(library1.id))
val syncPointBooks = syncPointRepository.findBooksById(syncPoint.id, false, Pageable.unpaged())
// then
assertThat(syncPoint.userId).isEqualTo(user1.id)
assertThat(syncPointBooks).hasSize(1)
with(syncPointBooks.first()) {
assertThat(this.bookId).isEqualTo(bookValid.id)
assertThat(this.fileHash).isEqualTo(bookValid.fileHash)
assertThat(this.fileSize).isEqualTo(bookValid.fileSize)
assertThat(this.fileLastModified).isCloseTo(bookValid.fileLastModified.toZonedDateTime(), within(1, ChronoUnit.SECONDS))
}
}
@Test
fun `given syncpoint when adding new books then syncpoint diff contains new books`() {
// given
val book1 = makeBook("valid", libraryId = library1.id)
val series =
makeSeries(name = "series1", libraryId = library1.id).also { series ->
seriesLifecycle.createSeries(series).let { created ->
seriesLifecycle.addBooks(created, listOf(book1))
}
}
mediaRepository.findById(book1.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = MediaType.EPUB.type)) }
val syncPoint1 = syncPointLifecycle.createSyncPoint(user1, null, listOf(library1.id))
// when
val book2 = makeBook("valid", libraryId = library1.id)
val book3 = makeBook("valid", libraryId = library1.id)
seriesLifecycle.addBooks(series, listOf(book2, book3))
mediaRepository.findById(book2.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = MediaType.EPUB.type)) }
mediaRepository.findById(book3.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = MediaType.EPUB.type)) }
val syncPoint2 = syncPointLifecycle.createSyncPoint(user1, null, listOf(library1.id))
val booksAdded = syncPointRepository.findBooksAdded(syncPoint1.id, syncPoint2.id, false, Pageable.unpaged())
val page1 = syncPointLifecycle.takeBooksAdded(syncPoint1.id, syncPoint2.id, Pageable.ofSize(1))
val page2 = syncPointLifecycle.takeBooksAdded(syncPoint1.id, syncPoint2.id, Pageable.ofSize(1))
// then
assertThat(booksAdded).hasSize(2)
assertThat(booksAdded.map { it.bookId }).containsExactlyInAnyOrder(book2.id, book3.id)
assertThat(page1).hasSize(1)
assertThat(page1.map { it.bookId })
.containsAnyElementsOf(listOf(book2.id, book3.id))
.doesNotContainAnyElementsOf(page2.map { it.bookId })
assertThat(page2).hasSize(1)
assertThat(page2.map { it.bookId })
.containsAnyElementsOf(listOf(book2.id, book3.id))
.doesNotContainAnyElementsOf(page1.map { it.bookId })
}
@Test
fun `given syncpoint when deleting books then syncpoint diff contains removed books`() {
// given
val book1 = makeBook("valid1", libraryId = library1.id)
val book2 = makeBook("valid2", libraryId = library1.id)
val book3 = makeBook("valid3", libraryId = library1.id)
val series =
makeSeries(name = "series1", libraryId = library1.id).also { series ->
seriesLifecycle.createSeries(series).let { created ->
seriesLifecycle.addBooks(created, listOf(book1, book2, book3))
}
}
bookRepository.findAll().forEach { mediaRepository.findById(it.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = MediaType.EPUB.type)) } }
val syncPoint1 = syncPointLifecycle.createSyncPoint(user1, null, listOf(library1.id))
// when
bookLifecycle.softDeleteMany(listOf(bookRepository.findByIdOrNull(book2.id)!!))
bookLifecycle.deleteOne(bookRepository.findByIdOrNull(book3.id)!!)
val syncPoint2 = syncPointLifecycle.createSyncPoint(user1, null, listOf(library1.id))
val booksRemoved = syncPointRepository.findBooksRemoved(syncPoint1.id, syncPoint2.id, false, Pageable.unpaged())
val page1 = syncPointLifecycle.takeBooksRemoved(syncPoint1.id, syncPoint2.id, Pageable.ofSize(1))
val page2 = syncPointLifecycle.takeBooksRemoved(syncPoint1.id, syncPoint2.id, Pageable.ofSize(1))
// then
assertThat(booksRemoved).hasSize(2)
assertThat(booksRemoved.map { it.bookId }).containsExactlyInAnyOrder(book2.id, book3.id)
assertThat(page1).hasSize(1)
assertThat(page1.map { it.bookId })
.containsAnyElementsOf(listOf(book2.id, book3.id))
.doesNotContainAnyElementsOf(page2.map { it.bookId })
assertThat(page2).hasSize(1)
assertThat(page2.map { it.bookId })
.containsAnyElementsOf(listOf(book2.id, book3.id))
.doesNotContainAnyElementsOf(page1.map { it.bookId })
}
@Test
fun `given syncpoint when changing books then syncpoint diff contains changed books`() {
// given
val book1 = makeBook("valid1", libraryId = library1.id)
val book2 = makeBook("valid2", libraryId = library1.id).copy(fileSize = 1, fileHash = "hash1")
val book3 = makeBook("valid3", libraryId = library1.id)
val book4 = makeBook("no hash to hash", libraryId = library1.id)
val series =
makeSeries(name = "series1", libraryId = library1.id).also { series ->
seriesLifecycle.createSeries(series).let { created ->
seriesLifecycle.addBooks(created, listOf(book1, book2, book3))
}
}
bookRepository.findAll().forEach { mediaRepository.findById(it.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = MediaType.EPUB.type)) } }
val syncPoint1 = syncPointLifecycle.createSyncPoint(user1, null, listOf(library1.id))
// when
bookRepository.findByIdOrNull(book1.id)?.let { bookRepository.update(it.copy(fileLastModified = LocalDateTime.of(2020, 1, 1, 1, 1))) }
bookRepository.findByIdOrNull(book2.id)?.let { bookRepository.update(it.copy(fileHash = "hash2")) }
bookMetadataRepository.findById(book3.id).let { bookMetadataRepository.update(it.copy(title = "changed")) }
bookRepository.findByIdOrNull(book4.id)?.let { bookRepository.update(it.copy(fileHash = "hash")) } // not included in changed books if it had no hash before
val syncPoint2 = syncPointLifecycle.createSyncPoint(user1, null, listOf(library1.id))
val booksChanged = syncPointRepository.findBooksChanged(syncPoint1.id, syncPoint2.id, false, Pageable.unpaged())
val page1 = syncPointLifecycle.takeBooksChanged(syncPoint1.id, syncPoint2.id, PageRequest.ofSize(1))
val page2 = syncPointLifecycle.takeBooksChanged(syncPoint1.id, syncPoint2.id, PageRequest.ofSize(1))
val page3 = syncPointLifecycle.takeBooksChanged(syncPoint1.id, syncPoint2.id, PageRequest.ofSize(1))
// then
assertThat(booksChanged).hasSize(3)
assertThat(booksChanged.map { it.bookId }).containsExactlyInAnyOrder(book1.id, book2.id, book3.id)
assertThat(page1).hasSize(1)
assertThat(page1.map { it.bookId })
.containsAnyElementsOf(listOf(book1.id, book2.id, book3.id))
.doesNotContainAnyElementsOf((page2 + page3).map { it.bookId })
assertThat(page2).hasSize(1)
assertThat(page2.map { it.bookId })
.containsAnyElementsOf(listOf(book1.id, book2.id, book3.id))
.doesNotContainAnyElementsOf((page1 + page3).map { it.bookId })
assertThat(page3).hasSize(1)
assertThat(page3.map { it.bookId })
.containsAnyElementsOf(listOf(book1.id, book2.id, book3.id))
.doesNotContainAnyElementsOf((page1 + page2).map { it.bookId })
}
}

View file

@ -47,6 +47,9 @@ class KomgaUserDaoTest(
email = "user@example.org",
password = "password",
roleAdmin = false,
rolePageStreaming = false,
roleFileDownload = false,
roleKoboSync = false,
sharedLibrariesIds = setOf(library.id),
sharedAllLibraries = false,
)
@ -61,6 +64,9 @@ class KomgaUserDaoTest(
assertThat(email).isEqualTo("user@example.org")
assertThat(password).isEqualTo("password")
assertThat(roleAdmin).isFalse
assertThat(rolePageStreaming).isFalse
assertThat(roleFileDownload).isFalse
assertThat(roleKoboSync).isFalse
assertThat(sharedLibrariesIds).containsExactly(library.id)
assertThat(sharedAllLibraries).isFalse
assertThat(restrictions.ageRestriction).isNull()
@ -76,6 +82,9 @@ class KomgaUserDaoTest(
email = "user@example.org",
password = "password",
roleAdmin = false,
rolePageStreaming = false,
roleFileDownload = false,
roleKoboSync = false,
sharedLibrariesIds = setOf(library.id),
sharedAllLibraries = false,
restrictions =
@ -101,6 +110,9 @@ class KomgaUserDaoTest(
email = "user2@example.org",
password = "password2",
roleAdmin = true,
rolePageStreaming = true,
roleFileDownload = true,
roleKoboSync = true,
sharedLibrariesIds = emptySet(),
sharedAllLibraries = true,
restrictions =
@ -123,6 +135,9 @@ class KomgaUserDaoTest(
assertThat(email).isEqualTo("user2@example.org")
assertThat(password).isEqualTo("password2")
assertThat(roleAdmin).isTrue
assertThat(rolePageStreaming).isTrue
assertThat(roleFileDownload).isTrue
assertThat(roleKoboSync).isTrue
assertThat(sharedLibrariesIds).isEmpty()
assertThat(sharedAllLibraries).isTrue
assertThat(restrictions.ageRestriction).isNotNull

View file

@ -0,0 +1,80 @@
package org.gotson.komga.infrastructure.kobo
import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.domain.model.KomgaSyncToken
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import java.util.Base64
@SpringBootTest
class KomgaSyncTokenGeneratorTest(
@Autowired private val tokenGenerator: KomgaSyncTokenGenerator,
) {
private val base64Encoder by lazy { Base64.getEncoder().withoutPadding() }
fun encodeToBase64(token: String): String = base64Encoder.encodeToString(token.toByteArray())
@Test
fun `given Kobo store token when getting token then it contains the kobo store token`() {
// given
val koboToken = "fake.token"
// when
val komgaToken = tokenGenerator.fromBase64(koboToken)
// then
assertThat(komgaToken.rawKoboSyncToken).isEqualTo(koboToken)
}
@Test
fun `given calibre web token when getting token then it contains the kobo store token`() {
// given
val calibreWebToken =
"""
{
"data": {
"archive_last_modified": -62135596800.0,
"books_last_created": -62135596800.0,
"books_last_modified": -62135596800.0,
"raw_kobo_store_token": "fake.token",
"reading_state_last_modified": -62135596800.0,
"tags_last_modified": -62135596800.0
},
"version": "1-1-0"
}
""".trimIndent()
// when
val komgaToken = tokenGenerator.fromBase64(encodeToBase64(calibreWebToken))
// then
assertThat(komgaToken.rawKoboSyncToken).isEqualTo("fake.token")
}
@Test
fun `given Komga token when getting token then it contains the kobo store token`() {
// given
val komgaToken = KomgaSyncToken(rawKoboSyncToken = "fake.token")
// when
val encodedToken = tokenGenerator.toBase64(komgaToken)
val decodedToken = tokenGenerator.fromBase64(encodedToken)
// then
assertThat(decodedToken.rawKoboSyncToken).isEqualTo("fake.token")
assertThat(encodedToken).startsWith("KOMGA.")
}
@Test
fun `given unidentified token when getting token then it is empty`() {
// given
val token = "unrecognized"
// when
val komgaToken = tokenGenerator.fromBase64(token)
// then
assertThat(komgaToken.rawKoboSyncToken).isEmpty()
}
}

View file

@ -0,0 +1,131 @@
package org.gotson.komga.interfaces.api.kobo
import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.MediaType
import org.gotson.komga.domain.model.makeBook
import org.gotson.komga.domain.model.makeLibrary
import org.gotson.komga.domain.model.makeSeries
import org.gotson.komga.domain.persistence.KomgaUserRepository
import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.persistence.MediaRepository
import org.gotson.komga.domain.persistence.SeriesRepository
import org.gotson.komga.domain.service.KomgaUserLifecycle
import org.gotson.komga.domain.service.SeriesLifecycle
import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider
import org.gotson.komga.infrastructure.kobo.KoboHeaders
import org.gotson.komga.infrastructure.kobo.KomgaSyncTokenGenerator
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.HttpHeaders
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get
@SpringBootTest(properties = ["komga.kobo.sync-item-limit=1"])
@AutoConfigureMockMvc(printOnlyOnFailure = false)
class KoboControllerTest(
@Autowired private val mockMvc: MockMvc,
@Autowired private val libraryRepository: LibraryRepository,
@Autowired private val userRepository: KomgaUserRepository,
@Autowired private val komgaUserLifecycle: KomgaUserLifecycle,
@Autowired private val seriesRepository: SeriesRepository,
@Autowired private val seriesLifecycle: SeriesLifecycle,
@Autowired private val mediaRepository: MediaRepository,
@Autowired private val komgaSyncTokenGenerator: KomgaSyncTokenGenerator,
@Autowired private val komgaSettingsProvider: KomgaSettingsProvider,
) {
private val library1 = makeLibrary()
private val user1 =
KomgaUser(
"user@example.org",
"",
false,
roleKoboSync = true,
)
private lateinit var apiKey: String
@BeforeAll
fun `setup library`() {
libraryRepository.insert(library1)
userRepository.insert(user1)
apiKey = komgaUserLifecycle.createApiKey(user1, "test")!!.key
}
@AfterAll
fun teardown() {
libraryRepository.deleteAll()
komgaUserLifecycle.deleteUser(user1)
}
@AfterEach
fun `clear repository`() {
seriesLifecycle.deleteMany(seriesRepository.findAll())
}
@Test
fun `given user when syncing for the first time then books are synced`() {
// given
val book1 = makeBook("valid", libraryId = library1.id)
val book2 = makeBook("valid", libraryId = library1.id)
val series =
makeSeries(name = "series1", libraryId = library1.id).also { series ->
seriesLifecycle.createSeries(series).let { created ->
seriesLifecycle.addBooks(created, listOf(book1, book2))
}
}
mediaRepository.findById(book1.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = MediaType.EPUB.type)) }
mediaRepository.findById(book2.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = MediaType.EPUB.type)) }
// first sync
val mvcResult1 =
mockMvc.get("/kobo/$apiKey/v1/library/sync")
.andExpect {
status { isOk() }
jsonPath("$.length()") { value(1) }
header { string(KoboHeaders.X_KOBO_SYNC, "continue") }
}.andReturn()
val syncToken1Base64 = mvcResult1.response.getHeaderValue(KoboHeaders.X_KOBO_SYNCTOKEN) as String
val syncToken1 = komgaSyncTokenGenerator.fromBase64(syncToken1Base64)
assertThat(syncToken1.ongoingSyncPointId).isNotEmpty()
assertThat(syncToken1.lastSuccessfulSyncPointId).isNull()
// second sync
val mvcResult2 =
mockMvc.get("/kobo/$apiKey/v1/library/sync") {
header(KoboHeaders.X_KOBO_SYNCTOKEN, syncToken1Base64)
}
.andExpect {
status { isOk() }
jsonPath("$.length()") { value(1) }
header { doesNotExist(KoboHeaders.X_KOBO_SYNC) }
}.andReturn()
val syncToken2 = komgaSyncTokenGenerator.fromBase64(mvcResult2.response.getHeaderValue(KoboHeaders.X_KOBO_SYNCTOKEN) as String)
assertThat(syncToken2.ongoingSyncPointId).isNull()
assertThat(syncToken2.lastSuccessfulSyncPointId).isEqualTo(syncToken1.ongoingSyncPointId)
}
@Test
fun `given kobo proxy is enabled when requesting book cover for non-existent book then redirect response is returned`() {
komgaSettingsProvider.koboProxy = true
try {
mockMvc.get("/kobo/$apiKey/v1/books/nonexistent/thumbnail/800/800/false/image.jpg")
.andExpect {
status { isTemporaryRedirect() }
header { string(HttpHeaders.LOCATION, "https://cdn.kobo.com/book-images/nonexistent/800/800/false/image.jpg") }
}
} finally {
komgaSettingsProvider.koboProxy = false
}
}
}

View file

@ -6,6 +6,7 @@ import org.gotson.komga.domain.model.ContentRestrictions
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD
import org.gotson.komga.domain.model.ROLE_KOBO_SYNC
import org.gotson.komga.domain.model.ROLE_PAGE_STREAMING
import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.infrastructure.security.apikey.ApiKeyAuthenticationToken
@ -20,7 +21,7 @@ import org.springframework.security.test.context.support.WithSecurityContextFact
@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory::class, setupBefore = TestExecutionEvent.TEST_EXECUTION)
annotation class WithMockCustomUser(
val email: String = "user@example.org",
val roles: Array<String> = [ROLE_FILE_DOWNLOAD, ROLE_PAGE_STREAMING],
val roles: Array<String> = [ROLE_FILE_DOWNLOAD, ROLE_PAGE_STREAMING, ROLE_KOBO_SYNC],
val sharedAllLibraries: Boolean = true,
val sharedLibraries: Array<String> = [],
val id: String = "0",
@ -43,6 +44,7 @@ class WithMockCustomUserSecurityContextFactory : WithSecurityContextFactory<With
roleAdmin = customUser.roles.contains(ROLE_ADMIN),
roleFileDownload = customUser.roles.contains(ROLE_FILE_DOWNLOAD),
rolePageStreaming = customUser.roles.contains(ROLE_PAGE_STREAMING),
roleKoboSync = customUser.roles.contains(ROLE_KOBO_SYNC),
sharedAllLibraries = customUser.sharedAllLibraries,
sharedLibrariesIds = customUser.sharedLibraries.toSet(),
restrictions =

View file

@ -7,6 +7,7 @@ import org.gotson.komga.domain.model.ContentRestrictions
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD
import org.gotson.komga.domain.model.ROLE_KOBO_SYNC
import org.gotson.komga.domain.model.ROLE_PAGE_STREAMING
import org.gotson.komga.domain.model.makeLibrary
import org.gotson.komga.domain.persistence.KomgaUserRepository
@ -97,7 +98,7 @@ class UserControllerTest(
val jsonString =
"""
{
"roles": ["$ROLE_FILE_DOWNLOAD","$ROLE_PAGE_STREAMING"]
"roles": ["$ROLE_FILE_DOWNLOAD","$ROLE_PAGE_STREAMING","$ROLE_KOBO_SYNC"]
}
""".trimIndent()
@ -112,6 +113,7 @@ class UserControllerTest(
assertThat(this).isNotNull
assertThat(this!!.roleFileDownload).isTrue
assertThat(this.rolePageStreaming).isTrue
assertThat(this.roleKoboSync).isTrue
assertThat(this.roleAdmin).isFalse
}
}