mirror of
https://github.com/gotson/komga.git
synced 2025-12-06 08:32:25 +01:00
feat(kobo): initial Kobo Sync support
This commit is contained in:
parent
a4747e81f4
commit
210c7b1e50
69 changed files with 2863 additions and 47 deletions
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -73,6 +73,8 @@ interface BookRepository {
|
|||
sort: Sort,
|
||||
): Collection<String>
|
||||
|
||||
fun existsById(bookId: String): Boolean
|
||||
|
||||
fun insert(book: Book)
|
||||
|
||||
fun insert(books: Collection<Book>)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: ")
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package org.gotson.komga.interfaces.api.kobo.dto
|
||||
|
||||
enum class FormatDto {
|
||||
EPUB3FL,
|
||||
EPUB,
|
||||
EPUB3,
|
||||
KEPUB,
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
|
@ -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(),
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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",
|
||||
)
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -36,4 +36,6 @@ class SettingsUpdateDto {
|
|||
by Delegates.observable(null) { prop, _, _ ->
|
||||
isSet[prop.name] = true
|
||||
}
|
||||
|
||||
var koboProxy: Boolean? = null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue