diff --git a/server/i18n/src/commonMain/moko-resources/values/base/strings.xml b/server/i18n/src/commonMain/moko-resources/values/base/strings.xml
index 10703314..b55c3da5 100644
--- a/server/i18n/src/commonMain/moko-resources/values/base/strings.xml
+++ b/server/i18n/src/commonMain/moko-resources/values/base/strings.xml
@@ -95,7 +95,12 @@
Next Page
Current Feed
View on Web
- View Pages (Streaming)
+ Read Online
+ Continue Reading Online
+ Read Online (Local Progress)
+ Continue Reading Online (Local Progress)
+ Read Online (Synced from %1$s)
+ Continue Reading Online (Synced from %1$s)
Download CBZ
Chapter Cover
View Chapter Details & Get Pages
@@ -106,7 +111,7 @@
⭕
⬇️
- %1$s | %2$s
+ Series: %1$s | %2$s
| By %1$s
| Progress: %1$d of %2$d
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/ChapterMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/ChapterMutation.kt
index 50824807..a73fd8b5 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/ChapterMutation.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/ChapterMutation.kt
@@ -1,6 +1,8 @@
package suwayomi.tachidesk.graphql.mutations
import graphql.execution.DataFetcherResult
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.and
@@ -8,11 +10,14 @@ import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.statements.BatchUpdateStatement
import org.jetbrains.exposed.sql.transactions.transaction
+import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.graphql.asDataFetcherResult
import suwayomi.tachidesk.graphql.types.ChapterMetaType
import suwayomi.tachidesk.graphql.types.ChapterType
+import suwayomi.tachidesk.graphql.types.SyncConflictInfoType
import suwayomi.tachidesk.manga.impl.Chapter
import suwayomi.tachidesk.manga.impl.chapter.getChapterDownloadReadyById
+import suwayomi.tachidesk.manga.impl.sync.KoreaderSyncService
import suwayomi.tachidesk.manga.model.table.ChapterMetaTable
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.server.JavalinSetup.future
@@ -68,8 +73,10 @@ class ChapterMutation {
ChapterTable
.select(ChapterTable.id, ChapterTable.pageCount)
.where { ChapterTable.id inList ids }
- .groupBy { it[ChapterTable.id].value }
- .mapValues { it.value.firstOrNull()?.let { it[ChapterTable.pageCount] } }
+ .associateBy(
+ { it[ChapterTable.id].value },
+ { it[ChapterTable.pageCount] },
+ )
} else {
emptyMap()
}
@@ -94,6 +101,15 @@ class ChapterMutation {
}
}
}
+
+ // Sync with KoreaderSync when progress is updated
+ if (patch.lastPageRead != null || patch.isRead == true) {
+ GlobalScope.launch {
+ ids.forEach { chapterId ->
+ KoreaderSyncService.pushProgress(chapterId)
+ }
+ }
+ }
}
fun updateChapter(input: UpdateChapterInput): DataFetcherResult =
@@ -241,6 +257,7 @@ class ChapterMutation {
val clientMutationId: String?,
val pages: List,
val chapter: ChapterType,
+ val syncConflict: SyncConflictInfoType?,
)
fun fetchChapterPages(input: FetchChapterPagesInput): CompletableFuture> {
@@ -249,7 +266,35 @@ class ChapterMutation {
return future {
asDataFetcherResult {
- val chapter = getChapterDownloadReadyById(chapterId)
+ var chapter = getChapterDownloadReadyById(chapterId)
+ val syncResult = KoreaderSyncService.checkAndPullProgress(chapter.id)
+ var syncConflictInfo: SyncConflictInfoType? = null
+
+ if (syncResult != null) {
+ if (syncResult.isConflict) {
+ syncConflictInfo =
+ SyncConflictInfoType(
+ deviceName = syncResult.device,
+ remotePage = syncResult.pageRead,
+ )
+ }
+
+ if (syncResult.shouldUpdate) {
+ // Update DB for SILENT and RECEIVE
+ transaction {
+ ChapterTable.update({ ChapterTable.id eq chapter.id }) {
+ it[lastPageRead] = syncResult.pageRead
+ it[lastReadAt] = syncResult.timestamp
+ }
+ }
+ }
+ // For PROMPT, SILENT, and RECEIVE, return the remote progress
+ chapter =
+ chapter.copy(
+ lastPageRead = if (syncResult.shouldUpdate) syncResult.pageRead else chapter.lastPageRead,
+ lastReadAt = if (syncResult.shouldUpdate) syncResult.timestamp else chapter.lastReadAt,
+ )
+ }
val params =
buildString {
@@ -273,6 +318,7 @@ class ChapterMutation {
"/api/v1/manga/${chapter.mangaId}/chapter/${chapter.index}/page/${index}$params"
},
chapter = ChapterType(chapter),
+ syncConflict = syncConflictInfo,
)
}
}
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/KoreaderSyncMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/KoreaderSyncMutation.kt
new file mode 100644
index 00000000..feafc6aa
--- /dev/null
+++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/KoreaderSyncMutation.kt
@@ -0,0 +1,43 @@
+package suwayomi.tachidesk.graphql.mutations
+
+import suwayomi.tachidesk.graphql.types.KoSyncConnectPayload
+import suwayomi.tachidesk.graphql.types.LogoutKoSyncAccountPayload
+import suwayomi.tachidesk.graphql.types.SettingsType
+import suwayomi.tachidesk.manga.impl.sync.KoreaderSyncService
+import suwayomi.tachidesk.server.JavalinSetup.future
+import java.util.concurrent.CompletableFuture
+
+class KoreaderSyncMutation {
+ data class ConnectKoSyncAccountInput(
+ val clientMutationId: String? = null,
+ val username: String,
+ val password: String,
+ )
+
+ fun connectKoSyncAccount(input: ConnectKoSyncAccountInput): CompletableFuture =
+ future {
+ val result = KoreaderSyncService.connect(input.username, input.password)
+
+ KoSyncConnectPayload(
+ clientMutationId = input.clientMutationId,
+ success = result.success,
+ message = result.message,
+ username = result.username,
+ settings = SettingsType(),
+ )
+ }
+
+ data class LogoutKoSyncAccountInput(
+ val clientMutationId: String? = null,
+ )
+
+ fun logoutKoSyncAccount(input: LogoutKoSyncAccountInput): CompletableFuture =
+ future {
+ KoreaderSyncService.logout()
+ LogoutKoSyncAccountPayload(
+ clientMutationId = input.clientMutationId,
+ success = true,
+ settings = SettingsType(),
+ )
+ }
+}
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SettingsMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SettingsMutation.kt
index 4cc0bc1c..09e5009c 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SettingsMutation.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SettingsMutation.kt
@@ -215,6 +215,15 @@ class SettingsMutation {
updateSetting(settings.opdsShowOnlyUnreadChapters, serverConfig.opdsShowOnlyUnreadChapters)
updateSetting(settings.opdsShowOnlyDownloadedChapters, serverConfig.opdsShowOnlyDownloadedChapters)
updateSetting(settings.opdsChapterSortOrder, serverConfig.opdsChapterSortOrder)
+
+ // koreader sync
+ updateSetting(settings.koreaderSyncServerUrl, serverConfig.koreaderSyncServerUrl)
+ updateSetting(settings.koreaderSyncUsername, serverConfig.koreaderSyncUsername)
+ updateSetting(settings.koreaderSyncUserkey, serverConfig.koreaderSyncUserkey)
+ updateSetting(settings.koreaderSyncDeviceId, serverConfig.koreaderSyncDeviceId)
+ updateSetting(settings.koreaderSyncChecksumMethod, serverConfig.koreaderSyncChecksumMethod)
+ updateSetting(settings.koreaderSyncStrategy, serverConfig.koreaderSyncStrategy)
+ updateSetting(settings.koreaderSyncPercentageTolerance, serverConfig.koreaderSyncPercentageTolerance)
}
fun setSettings(input: SetSettingsInput): SetSettingsPayload {
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/KoreaderSyncQuery.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/KoreaderSyncQuery.kt
new file mode 100644
index 00000000..dd20e406
--- /dev/null
+++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/KoreaderSyncQuery.kt
@@ -0,0 +1,13 @@
+package suwayomi.tachidesk.graphql.queries
+
+import suwayomi.tachidesk.graphql.types.KoSyncStatusPayload
+import suwayomi.tachidesk.manga.impl.sync.KoreaderSyncService
+import suwayomi.tachidesk.server.JavalinSetup.future
+import java.util.concurrent.CompletableFuture
+
+class KoreaderSyncQuery {
+ fun koSyncStatus(): CompletableFuture =
+ future {
+ KoreaderSyncService.getStatus()
+ }
+}
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLSchema.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLSchema.kt
index ae2ce146..9af55a9b 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLSchema.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLSchema.kt
@@ -20,6 +20,7 @@ import suwayomi.tachidesk.graphql.mutations.DownloadMutation
import suwayomi.tachidesk.graphql.mutations.ExtensionMutation
import suwayomi.tachidesk.graphql.mutations.ImageMutation
import suwayomi.tachidesk.graphql.mutations.InfoMutation
+import suwayomi.tachidesk.graphql.mutations.KoreaderSyncMutation
import suwayomi.tachidesk.graphql.mutations.MangaMutation
import suwayomi.tachidesk.graphql.mutations.MetaMutation
import suwayomi.tachidesk.graphql.mutations.SettingsMutation
@@ -32,6 +33,7 @@ import suwayomi.tachidesk.graphql.queries.ChapterQuery
import suwayomi.tachidesk.graphql.queries.DownloadQuery
import suwayomi.tachidesk.graphql.queries.ExtensionQuery
import suwayomi.tachidesk.graphql.queries.InfoQuery
+import suwayomi.tachidesk.graphql.queries.KoreaderSyncQuery
import suwayomi.tachidesk.graphql.queries.MangaQuery
import suwayomi.tachidesk.graphql.queries.MetaQuery
import suwayomi.tachidesk.graphql.queries.SettingsQuery
@@ -74,6 +76,7 @@ val schema =
TopLevelObject(DownloadQuery()),
TopLevelObject(ExtensionQuery()),
TopLevelObject(InfoQuery()),
+ TopLevelObject(KoreaderSyncQuery()),
TopLevelObject(MangaQuery()),
TopLevelObject(MetaQuery()),
TopLevelObject(SettingsQuery()),
@@ -90,6 +93,7 @@ val schema =
TopLevelObject(ExtensionMutation()),
TopLevelObject(ImageMutation()),
TopLevelObject(InfoMutation()),
+ TopLevelObject(KoreaderSyncMutation()),
TopLevelObject(MangaMutation()),
TopLevelObject(MetaMutation()),
TopLevelObject(SettingsMutation()),
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/ChapterType.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/ChapterType.kt
index e12d4b7c..09c30f90 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/ChapterType.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/ChapterType.kt
@@ -19,6 +19,11 @@ import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
import suwayomi.tachidesk.manga.model.table.ChapterTable
import java.util.concurrent.CompletableFuture
+data class SyncConflictInfoType(
+ val deviceName: String,
+ val remotePage: Int,
+)
+
class ChapterType(
val id: Int,
val url: String,
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/KoreaderSyncTypes.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/KoreaderSyncTypes.kt
new file mode 100644
index 00000000..26343f7f
--- /dev/null
+++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/KoreaderSyncTypes.kt
@@ -0,0 +1,33 @@
+package suwayomi.tachidesk.graphql.types
+
+enum class KoreaderSyncChecksumMethod {
+ BINARY,
+ FILENAME,
+}
+
+enum class KoreaderSyncStrategy {
+ PROMPT, // Ask on conflict
+ SILENT, // Always use latest
+ SEND, // Send changes only
+ RECEIVE, // Receive changes only
+ DISABLED,
+}
+
+data class KoSyncStatusPayload(
+ val isLoggedIn: Boolean,
+ val username: String?,
+)
+
+data class KoSyncConnectPayload(
+ val clientMutationId: String?,
+ val success: Boolean,
+ val message: String?,
+ val username: String?,
+ val settings: SettingsType,
+)
+
+data class LogoutKoSyncAccountPayload(
+ val clientMutationId: String?,
+ val success: Boolean,
+ val settings: SettingsType,
+)
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt
index c5f66c38..2bac6679 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt
@@ -112,6 +112,15 @@ interface Settings : Node {
val opdsShowOnlyUnreadChapters: Boolean?
val opdsShowOnlyDownloadedChapters: Boolean?
val opdsChapterSortOrder: SortOrder?
+
+ // koreader sync
+ val koreaderSyncServerUrl: String?
+ val koreaderSyncUsername: String?
+ val koreaderSyncUserkey: String?
+ val koreaderSyncDeviceId: String?
+ val koreaderSyncChecksumMethod: KoreaderSyncChecksumMethod?
+ val koreaderSyncStrategy: KoreaderSyncStrategy?
+ val koreaderSyncPercentageTolerance: Double?
}
interface SettingsDownloadConversion {
@@ -206,6 +215,14 @@ data class PartialSettingsType(
override val opdsShowOnlyUnreadChapters: Boolean?,
override val opdsShowOnlyDownloadedChapters: Boolean?,
override val opdsChapterSortOrder: SortOrder?,
+ // koreader sync
+ override val koreaderSyncServerUrl: String?,
+ override val koreaderSyncUsername: String?,
+ override val koreaderSyncUserkey: String?,
+ override val koreaderSyncDeviceId: String?,
+ override val koreaderSyncChecksumMethod: KoreaderSyncChecksumMethod?,
+ override val koreaderSyncStrategy: KoreaderSyncStrategy?,
+ override val koreaderSyncPercentageTolerance: Double?,
) : Settings
class SettingsType(
@@ -288,6 +305,14 @@ class SettingsType(
override val opdsShowOnlyUnreadChapters: Boolean,
override val opdsShowOnlyDownloadedChapters: Boolean,
override val opdsChapterSortOrder: SortOrder,
+ // koreader sync
+ override val koreaderSyncServerUrl: String,
+ override val koreaderSyncUsername: String,
+ override val koreaderSyncUserkey: String,
+ override val koreaderSyncDeviceId: String,
+ override val koreaderSyncChecksumMethod: KoreaderSyncChecksumMethod,
+ override val koreaderSyncStrategy: KoreaderSyncStrategy,
+ override val koreaderSyncPercentageTolerance: Double,
) : Settings {
constructor(config: ServerConfig = serverConfig) : this(
config.ip.value,
@@ -367,5 +392,13 @@ class SettingsType(
config.opdsShowOnlyUnreadChapters.value,
config.opdsShowOnlyDownloadedChapters.value,
config.opdsChapterSortOrder.value,
+ // koreader sync
+ config.koreaderSyncServerUrl.value,
+ config.koreaderSyncUsername.value,
+ config.koreaderSyncUserkey.value,
+ config.koreaderSyncDeviceId.value,
+ config.koreaderSyncChecksumMethod.value,
+ config.koreaderSyncStrategy.value,
+ config.koreaderSyncPercentageTolerance.value,
)
}
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt
index c875d15b..af4a5c50 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt
@@ -9,7 +9,11 @@ package suwayomi.tachidesk.manga.controller
import io.javalin.http.HandlerType
import io.javalin.http.HttpStatus
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
+import org.jetbrains.exposed.sql.transactions.transaction
+import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.manga.impl.CategoryManga
import suwayomi.tachidesk.manga.impl.Chapter
import suwayomi.tachidesk.manga.impl.ChapterDownloadHelper
@@ -17,9 +21,11 @@ import suwayomi.tachidesk.manga.impl.Library
import suwayomi.tachidesk.manga.impl.Manga
import suwayomi.tachidesk.manga.impl.Page
import suwayomi.tachidesk.manga.impl.chapter.getChapterDownloadReadyByIndex
+import suwayomi.tachidesk.manga.impl.sync.KoreaderSyncService
import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
+import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.server.JavalinSetup.future
import suwayomi.tachidesk.server.util.formParam
import suwayomi.tachidesk.server.util.handler
@@ -315,8 +321,29 @@ object MangaController {
},
behaviorOf = { ctx, mangaId, chapterIndex ->
ctx.future {
- future { getChapterDownloadReadyByIndex(chapterIndex, mangaId) }
- .thenApply { ctx.json(it) }
+ future {
+ var chapter = getChapterDownloadReadyByIndex(chapterIndex, mangaId)
+ val syncResult = KoreaderSyncService.checkAndPullProgress(chapter.id)
+
+ if (syncResult != null) {
+ if (syncResult.shouldUpdate) {
+ // Update DB for SILENT and RECEIVE
+ transaction {
+ ChapterTable.update({ ChapterTable.id eq chapter.id }) {
+ it[lastPageRead] = syncResult.pageRead
+ it[lastReadAt] = syncResult.timestamp
+ }
+ }
+ }
+ // For PROMPT, SILENT, and RECEIVE, return the remote progress
+ chapter =
+ chapter.copy(
+ lastPageRead = syncResult.pageRead,
+ lastReadAt = syncResult.timestamp,
+ )
+ }
+ chapter
+ }.thenApply { ctx.json(it) }
}
},
withResults = {
@@ -341,7 +368,12 @@ object MangaController {
}
},
behaviorOf = { ctx, mangaId, chapterIndex, read, bookmarked, markPrevRead, lastPageRead ->
- Chapter.modifyChapter(mangaId, chapterIndex, read, bookmarked, markPrevRead, lastPageRead)
+ val chapterId = Chapter.modifyChapter(mangaId, chapterIndex, read, bookmarked, markPrevRead, lastPageRead)
+
+ // Sync with KoreaderSync when progress is updated
+ if (lastPageRead != null || read == true) {
+ GlobalScope.launch { KoreaderSyncService.pushProgress(chapterId) }
+ }
ctx.status(200)
},
@@ -422,7 +454,11 @@ object MangaController {
ctx.result(it.first)
if (updateProgress == true) {
- Chapter.updateChapterProgress(mangaId, chapterIndex, pageNo = index)
+ val chapterId = Chapter.updateChapterProgress(mangaId, chapterIndex, pageNo = index)
+ // Sync progress with KoreaderSync if chapter update was successful
+ if (chapterId != -1) {
+ GlobalScope.launch { KoreaderSyncService.pushProgress(chapterId) }
+ }
}
}
}
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt
index 55017f28..d3fceb71 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt
@@ -431,33 +431,46 @@ object Chapter {
isBookmarked: Boolean?,
markPrevRead: Boolean?,
lastPageRead: Int?,
- ) {
- transaction {
- if (listOf(isRead, isBookmarked, lastPageRead).any { it != null }) {
- ChapterTable.update({ (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder eq chapterIndex) }) { update ->
- isRead?.also {
- update[ChapterTable.isRead] = it
- }
- isBookmarked?.also {
- update[ChapterTable.isBookmarked] = it
- }
- lastPageRead?.also {
- update[ChapterTable.lastPageRead] = it
- update[lastReadAt] = Instant.now().epochSecond
- }
- }
- }
+ ): Int {
+ val chapterId =
+ transaction {
+ val chapter =
+ ChapterTable
+ .selectAll()
+ .where { (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder eq chapterIndex) }
+ .first()
- markPrevRead?.let {
- ChapterTable.update({ (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder less chapterIndex) }) {
- it[ChapterTable.isRead] = markPrevRead
+ val chapterIdValue = chapter[ChapterTable.id].value
+
+ if (listOf(isRead, isBookmarked, lastPageRead).any { it != null }) {
+ ChapterTable.update({ (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder eq chapterIndex) }) { update ->
+ isRead?.also {
+ update[ChapterTable.isRead] = it
+ }
+ isBookmarked?.also {
+ update[ChapterTable.isBookmarked] = it
+ }
+ lastPageRead?.also {
+ update[ChapterTable.lastPageRead] = it
+ update[lastReadAt] = Instant.now().epochSecond
+ }
+ }
}
+
+ markPrevRead?.let {
+ ChapterTable.update({ (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder less chapterIndex) }) {
+ it[ChapterTable.isRead] = markPrevRead
+ }
+ }
+
+ chapterIdValue
}
- }
if (isRead == true || markPrevRead == true) {
Track.asyncTrackChapter(setOf(mangaId))
}
+
+ return chapterId
}
@Serializable
@@ -733,7 +746,7 @@ object Chapter {
mangaId: Int,
chapterIndex: Int,
pageNo: Int,
- ) {
+ ): Int {
val chapterData =
transaction {
ChapterTable
@@ -756,5 +769,7 @@ object Chapter {
isBookmarked = null,
markPrevRead = null,
)
+
+ return chapterData.id
}
}
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupExport.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupExport.kt
index d7846d1e..c2003ac1 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupExport.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupExport.kt
@@ -463,6 +463,14 @@ object ProtoBackupExport : ProtoBackupBase() {
opdsShowOnlyUnreadChapters = serverConfig.opdsShowOnlyUnreadChapters.value,
opdsShowOnlyDownloadedChapters = serverConfig.opdsShowOnlyDownloadedChapters.value,
opdsChapterSortOrder = serverConfig.opdsChapterSortOrder.value,
+ // koreader sync
+ koreaderSyncServerUrl = serverConfig.koreaderSyncServerUrl.value,
+ koreaderSyncUsername = serverConfig.koreaderSyncUsername.value,
+ koreaderSyncUserkey = serverConfig.koreaderSyncUserkey.value,
+ koreaderSyncDeviceId = serverConfig.koreaderSyncDeviceId.value,
+ koreaderSyncChecksumMethod = serverConfig.koreaderSyncChecksumMethod.value,
+ koreaderSyncStrategy = serverConfig.koreaderSyncStrategy.value,
+ koreaderSyncPercentageTolerance = serverConfig.koreaderSyncPercentageTolerance.value,
)
}
}
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupServerSettings.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupServerSettings.kt
index dae300d2..6e9c1510 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupServerSettings.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupServerSettings.kt
@@ -4,6 +4,8 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
import org.jetbrains.exposed.sql.SortOrder
import suwayomi.tachidesk.graphql.types.AuthMode
+import suwayomi.tachidesk.graphql.types.KoreaderSyncChecksumMethod
+import suwayomi.tachidesk.graphql.types.KoreaderSyncStrategy
import suwayomi.tachidesk.graphql.types.Settings
import suwayomi.tachidesk.graphql.types.SettingsDownloadConversion
import suwayomi.tachidesk.graphql.types.WebUIChannel
@@ -84,6 +86,14 @@ data class BackupServerSettings(
@ProtoNumber(53) override var opdsShowOnlyUnreadChapters: Boolean,
@ProtoNumber(54) override var opdsShowOnlyDownloadedChapters: Boolean,
@ProtoNumber(55) override var opdsChapterSortOrder: SortOrder,
+ // koreader sync
+ @ProtoNumber(59) override var koreaderSyncServerUrl: String,
+ @ProtoNumber(60) override var koreaderSyncUsername: String,
+ @ProtoNumber(61) override var koreaderSyncUserkey: String,
+ @ProtoNumber(62) override var koreaderSyncDeviceId: String,
+ @ProtoNumber(63) override var koreaderSyncChecksumMethod: KoreaderSyncChecksumMethod,
+ @ProtoNumber(64) override var koreaderSyncStrategy: KoreaderSyncStrategy,
+ @ProtoNumber(65) override var koreaderSyncPercentageTolerance: Double,
) : Settings {
@Serializable
class BackupSettingsDownloadConversionType(
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/ChaptersFilesProvider.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/ChaptersFilesProvider.kt
index 939b7a7d..fa48312d 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/ChaptersFilesProvider.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/ChaptersFilesProvider.kt
@@ -13,11 +13,14 @@ import libcore.net.MimeUtils
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
+import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.manga.impl.Page
import suwayomi.tachidesk.manga.impl.chapter.getChapterDownloadReady
import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter
+import suwayomi.tachidesk.manga.impl.util.KoreaderHelper
import suwayomi.tachidesk.manga.impl.util.createComicInfoFile
import suwayomi.tachidesk.manga.impl.util.getChapterCachePath
+import suwayomi.tachidesk.manga.impl.util.getChapterCbzPath
import suwayomi.tachidesk.manga.impl.util.getChapterDownloadPath
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse
import suwayomi.tachidesk.manga.model.table.ChapterTable
@@ -43,20 +46,20 @@ sealed class FileType {
fun getName(): String =
when (this) {
- is FileType.RegularFile -> {
+ is RegularFile -> {
this.file.name
}
- is FileType.ZipFile -> {
+ is ZipFile -> {
this.entry.name
}
}
fun getExtension(): String =
when (this) {
- is FileType.RegularFile -> {
+ is RegularFile -> {
this.file.extension
}
- is FileType.ZipFile -> {
+ is ZipFile -> {
this.entry.name.substringAfterLast(".")
}
}
@@ -189,6 +192,19 @@ abstract class ChaptersFilesProvider(
handleSuccessfulDownload()
+ // Calculate and save Koreader hash for CBZ files
+ val chapterFile = File(getChapterCbzPath(mangaId, chapterId))
+ if (chapterFile.exists()) {
+ val koreaderHash = KoreaderHelper.hashContents(chapterFile)
+ if (koreaderHash != null) {
+ transaction {
+ ChapterTable.update({ ChapterTable.id eq chapterId }) {
+ it[ChapterTable.koreaderHash] = koreaderHash
+ }
+ }
+ }
+ }
+
File(cacheChapterDir).deleteRecursively()
return true
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/impl/ArchiveProvider.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/impl/ArchiveProvider.kt
index 4caa9e87..f3ca3549 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/impl/ArchiveProvider.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/impl/ArchiveProvider.kt
@@ -6,6 +6,8 @@ import org.apache.commons.compress.archivers.zip.ZipArchiveEntry
import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream
import org.apache.commons.compress.archivers.zip.ZipFile
+import org.jetbrains.exposed.sql.transactions.transaction
+import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.manga.impl.download.fileProvider.ChaptersFilesProvider
import suwayomi.tachidesk.manga.impl.download.fileProvider.FileType
import suwayomi.tachidesk.manga.impl.util.getChapterCachePath
@@ -13,6 +15,7 @@ import suwayomi.tachidesk.manga.impl.util.getChapterCbzPath
import suwayomi.tachidesk.manga.impl.util.getChapterDownloadPath
import suwayomi.tachidesk.manga.impl.util.getMangaDownloadDir
import suwayomi.tachidesk.manga.impl.util.storage.FileDeletionHelper
+import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.server.ApplicationDirs
import uy.kohesive.injekt.injectLazy
import java.io.File
@@ -85,6 +88,13 @@ class ArchiveProvider(
}
val cbzDeleted = cbzFile.delete()
+ if (cbzDeleted) {
+ transaction {
+ ChapterTable.update({ ChapterTable.id eq chapterId }) {
+ it[koreaderHash] = null
+ }
+ }
+ }
FileDeletionHelper.cleanupParentFoldersFor(cbzFile, applicationDirs.mangaDownloadsRoot)
return cbzDeleted
}
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/impl/FolderProvider.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/impl/FolderProvider.kt
index 5f84c7b7..c11296fd 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/impl/FolderProvider.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/impl/FolderProvider.kt
@@ -1,10 +1,13 @@
package suwayomi.tachidesk.manga.impl.download.fileProvider.impl
+import org.jetbrains.exposed.sql.transactions.transaction
+import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.manga.impl.download.fileProvider.ChaptersFilesProvider
import suwayomi.tachidesk.manga.impl.download.fileProvider.FileType.RegularFile
import suwayomi.tachidesk.manga.impl.util.getChapterCachePath
import suwayomi.tachidesk.manga.impl.util.getChapterDownloadPath
import suwayomi.tachidesk.manga.impl.util.storage.FileDeletionHelper
+import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.server.ApplicationDirs
import uy.kohesive.injekt.injectLazy
import java.io.BufferedOutputStream
@@ -61,6 +64,13 @@ class FolderProvider(
}
val chapterDirDeleted = chapterDir.deleteRecursively()
+ if (chapterDirDeleted) {
+ transaction {
+ ChapterTable.update({ ChapterTable.id eq chapterId }) {
+ it[koreaderHash] = null
+ }
+ }
+ }
FileDeletionHelper.cleanupParentFoldersFor(chapterDir, applicationDirs.mangaDownloadsRoot)
return chapterDirDeleted
}
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/sync/KoreaderSyncService.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/sync/KoreaderSyncService.kt
new file mode 100644
index 00000000..3275980c
--- /dev/null
+++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/sync/KoreaderSyncService.kt
@@ -0,0 +1,425 @@
+package suwayomi.tachidesk.manga.impl.sync
+
+import eu.kanade.tachiyomi.network.NetworkHelper
+import eu.kanade.tachiyomi.network.await
+import eu.kanade.tachiyomi.util.lang.Hash
+import io.github.oshai.kotlinlogging.KotlinLogging
+import io.javalin.json.JsonMapper
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.jetbrains.exposed.sql.transactions.transaction
+import org.jetbrains.exposed.sql.update
+import suwayomi.tachidesk.graphql.types.KoSyncStatusPayload
+import suwayomi.tachidesk.graphql.types.KoreaderSyncChecksumMethod
+import suwayomi.tachidesk.graphql.types.KoreaderSyncStrategy
+import suwayomi.tachidesk.manga.impl.util.KoreaderHelper
+import suwayomi.tachidesk.manga.impl.util.getChapterCbzPath
+import suwayomi.tachidesk.manga.model.table.ChapterTable
+import suwayomi.tachidesk.manga.model.table.MangaTable
+import suwayomi.tachidesk.server.serverConfig
+import uy.kohesive.injekt.injectLazy
+import java.io.File
+import java.util.UUID
+import kotlin.math.abs
+
+object KoreaderSyncService {
+ private val logger = KotlinLogging.logger {}
+ private val network: NetworkHelper by injectLazy()
+ private val json: Json by injectLazy()
+ private val jsonMapper: JsonMapper by injectLazy()
+
+ @Serializable
+ private data class KoreaderProgressPayload(
+ val document: String,
+ val progress: String,
+ val percentage: Float,
+ val device: String,
+ val device_id: String,
+ )
+
+ @Serializable
+ private data class KoreaderProgressResponse(
+ val document: String? = null,
+ val progress: String? = null,
+ val percentage: Float? = null,
+ val timestamp: Long? = null,
+ val device: String? = null,
+ val device_id: String? = null,
+ )
+
+ @Serializable
+ data class SyncResult(
+ val pageRead: Int,
+ val timestamp: Long, // Unix timestamp in seconds
+ val device: String,
+ val shouldUpdate: Boolean = false,
+ val isConflict: Boolean = false,
+ )
+
+ data class ConnectResult(
+ val success: Boolean,
+ val message: String? = null,
+ val username: String? = null,
+ )
+
+ private data class AuthResult(
+ val success: Boolean,
+ val message: String? = null,
+ val isUserNotFoundError: Boolean = false,
+ )
+
+ private fun buildRequest(
+ url: String,
+ block: Request.Builder.() -> Unit,
+ ): Request =
+ Request
+ .Builder()
+ .url(url)
+ .addHeader("Accept", "application/vnd.koreader.v1+json")
+ .apply(block)
+ .build()
+
+ private suspend fun getOrGenerateDeviceId(): String {
+ var deviceId = serverConfig.koreaderSyncDeviceId.value
+ if (deviceId.isBlank()) {
+ deviceId =
+ UUID
+ .randomUUID()
+ .toString()
+ .replace("-", "")
+ .uppercase()
+ logger.info { "[KOSYNC] Generated new KOSync Device ID: $deviceId" }
+ serverConfig.koreaderSyncDeviceId.value = deviceId
+ }
+ return deviceId
+ }
+
+ private fun getOrGenerateChapterHash(chapterId: Int): String? {
+ return transaction {
+ val existingHash =
+ ChapterTable
+ .select(ChapterTable.koreaderHash)
+ .where { ChapterTable.id eq chapterId }
+ .firstOrNull()
+ ?.get(ChapterTable.koreaderHash)
+
+ if (!existingHash.isNullOrBlank()) {
+ return@transaction existingHash
+ }
+
+ val checksumMethod = serverConfig.koreaderSyncChecksumMethod.value
+ val newHash =
+ when (checksumMethod) {
+ KoreaderSyncChecksumMethod.BINARY -> {
+ logger.info { "[KOSYNC HASH] No hash for chapterId=$chapterId. Generating from CBZ content." }
+ val mangaId =
+ ChapterTable
+ .select(ChapterTable.manga)
+ .where { ChapterTable.id eq chapterId }
+ .firstOrNull()
+ ?.get(ChapterTable.manga)
+ ?.value ?: return@transaction null
+ val cbzFile = File(getChapterCbzPath(mangaId, chapterId))
+ if (!cbzFile.exists()) {
+ logger.info { "[KOSYNC HASH] Could not generate hash for chapterId=$chapterId. CBZ not found." }
+ return@transaction null
+ }
+ KoreaderHelper.hashContents(cbzFile)
+ }
+ KoreaderSyncChecksumMethod.FILENAME -> {
+ logger.info { "[KOSYNC HASH] No hash for chapterId=$chapterId. Generating from filename." }
+ (ChapterTable innerJoin MangaTable)
+ .select(ChapterTable.name, MangaTable.title)
+ .where { ChapterTable.id eq chapterId }
+ .firstOrNull()
+ ?.let {
+ val chapterName = it[ChapterTable.name]
+ val mangaTitle = it[MangaTable.title]
+ val baseFilename = "$mangaTitle - $chapterName".split('.').dropLast(1).joinToString(".")
+ Hash.md5(baseFilename)
+ }
+ }
+ }
+
+ if (newHash != null) {
+ ChapterTable.update({ ChapterTable.id eq chapterId }) {
+ it[koreaderHash] = newHash
+ }
+ logger.info { "[KOSYNC HASH] Generated and saved new hash for chapterId=$chapterId" }
+ } else {
+ logger.warn { "[KOSYNC HASH] Hashing failed for chapterId=$chapterId." }
+ }
+ newHash
+ }
+ }
+
+ private suspend fun register(
+ username: String,
+ userkey: String,
+ ): AuthResult {
+ val payload =
+ buildJsonObject {
+ put("username", username)
+ put("password", userkey)
+ }
+ val request =
+ buildRequest("${serverConfig.koreaderSyncServerUrl.value.removeSuffix("/")}/users/create") {
+ post(payload.toString().toRequestBody("application/json".toMediaType()))
+ }
+
+ return try {
+ network.client.newCall(request).await().use { response ->
+ if (response.isSuccessful) {
+ AuthResult(true, "Registration successful.")
+ } else {
+ val errorBody = response.body.string()
+ val errorMessage =
+ runCatching {
+ jsonMapper.fromJsonString