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 812cbd9f..cad690ec 100644
--- a/server/i18n/src/commonMain/moko-resources/values/base/strings.xml
+++ b/server/i18n/src/commonMain/moko-resources/values/base/strings.xml
@@ -110,6 +110,7 @@
⌛
⭕
⬇️
+ 🌐
Series: %1$s | %2$s
| By %1$s
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
index 3275980c..e9b59913 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/sync/KoreaderSyncService.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/sync/KoreaderSyncService.kt
@@ -47,7 +47,7 @@ object KoreaderSyncService {
val document: String? = null,
val progress: String? = null,
val percentage: Float? = null,
- val timestamp: Long? = null,
+ val updated_at: Long? = null,
val device: String? = null,
val device_id: String? = null,
)
@@ -81,6 +81,7 @@ object KoreaderSyncService {
.Builder()
.url(url)
.addHeader("Accept", "application/vnd.koreader.v1+json")
+ .addHeader("Connection", "close")
.apply(block)
.build()
@@ -318,7 +319,13 @@ object KoreaderSyncService {
addHeader("x-auth-key", userkey)
}
+ logger.info { "[KOSYNC PUSH] PUT request to URL: ${request.url}" }
+ logger.info { "[KOSYNC PUSH] Sending data: $requestBody" }
+
network.client.newCall(request).await().use { response ->
+ val responseBody = response.body.string()
+ logger.info { "[KOSYNC PUSH] PUT response status: ${response.code}" }
+ logger.info { "[KOSYNC PUSH] PUT response body: $responseBody" }
if (!response.isSuccessful) {
logger.warn { "[KOSYNC PUSH] Failed for chapterId=$chapterId: ${response.code}" }
} else {
@@ -351,15 +358,19 @@ object KoreaderSyncService {
addHeader("x-auth-user", username)
addHeader("x-auth-key", userkey)
}
+ logger.info { "[KOSYNC PULL] GET request to URL: ${request.url}" }
network.client.newCall(request).await().use { response ->
+ logger.info { "[KOSYNC PULL] GET response status: ${response.code}" }
+
if (response.isSuccessful) {
val body = response.body.string()
+ logger.info { "[KOSYNC PULL] GET response body: $body" }
if (body.isBlank() || body == "{}") return null
val progressResponse = json.decodeFromString(KoreaderProgressResponse.serializer(), body)
val pageRead = progressResponse.progress?.toIntOrNull()?.minus(1)
- val timestamp = progressResponse.timestamp
+ val timestamp = progressResponse.updated_at
val device = progressResponse.device ?: "KOReader"
val localProgress =
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsEntryBuilder.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsEntryBuilder.kt
index 1f39d575..ed8a2584 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsEntryBuilder.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsEntryBuilder.kt
@@ -209,40 +209,122 @@ object OpdsEntryBuilder {
}
/**
- * Creates an OPDS entry for a chapter's metadata, used when page count is not initially available.
+ * Creates one or two OPDS entries for a chapter, handling synchronization conflicts internally.
+ *
* @param chapter The chapter metadata object.
* @param manga The parent manga's details.
* @param baseUrl The base URL for constructing links.
* @param locale The locale for localization.
- * @return An [OpdsEntryXml] object for the chapter's metadata.
+ * @return A `Pair` where the first element is the primary entry (always present) and the
+ * second is an optional entry representing the remote progress in case of a conflict.
*/
- suspend fun createChapterMetadataEntry(
+ suspend fun createChapterMetadataEntries(
chapter: OpdsChapterMetadataAcqEntry,
manga: OpdsMangaDetails,
baseUrl: String,
locale: Locale,
- ): OpdsEntryXml {
+ ): Pair {
// Check remote progress before building the entry
val syncResult = KoreaderSyncService.checkAndPullProgress(chapter.id)
- val localLastPageRead = chapter.lastPageRead
- val remoteLastPageRead = syncResult?.pageRead
- val remoteLastReadAt = syncResult?.timestamp
+ // Exists a conflict if the sync service reports a conflict and the page numbers differ.
+ val hasConflict = syncResult?.isConflict == true && syncResult.pageRead != chapter.lastPageRead
- val showConflict = syncResult?.isConflict == true && remoteLastPageRead != null && localLastPageRead != remoteLastPageRead
+ if (hasConflict) {
+ // Generate two entries: one for local progress and another for remote.
+ val localEntry =
+ buildSingleChapterMetadataEntry(
+ chapter,
+ manga,
+ baseUrl,
+ locale,
+ progressSource = ProgressSource.Local(chapter.lastPageRead, chapter.lastReadAt),
+ isConflict = true,
+ )
- val finalLastPageRead = if (syncResult?.shouldUpdate == true) remoteLastPageRead ?: localLastPageRead else localLastPageRead
- val finalLastReadAt = if (syncResult?.shouldUpdate == true) remoteLastReadAt ?: chapter.lastReadAt else chapter.lastReadAt
+ val remoteEntry =
+ buildSingleChapterMetadataEntry(
+ chapter,
+ manga,
+ baseUrl,
+ locale,
+ progressSource = ProgressSource.Remote(syncResult!!.pageRead, syncResult.timestamp, syncResult.device),
+ isConflict = true,
+ )
+ return Pair(localEntry, remoteEntry)
+ } else {
+ // No conflict, generate a single entry. Use remote progress if a silent update occurred.
+ val progressSource =
+ if (syncResult?.shouldUpdate == true) {
+ ProgressSource.Remote(syncResult.pageRead, syncResult.timestamp, syncResult.device)
+ } else {
+ ProgressSource.Local(chapter.lastPageRead, chapter.lastReadAt)
+ }
- val statusKey =
- when {
- chapter.downloaded -> MR.strings.opds_chapter_status_downloaded
- chapter.read -> MR.strings.opds_chapter_status_read
- finalLastPageRead > 0 -> MR.strings.opds_chapter_status_in_progress
- else -> MR.strings.opds_chapter_status_unread
+ val mainEntry =
+ buildSingleChapterMetadataEntry(
+ chapter,
+ manga,
+ baseUrl,
+ locale,
+ progressSource = progressSource,
+ isConflict = false,
+ )
+ return Pair(mainEntry, null)
+ }
+ }
+
+ /**
+ * Represents the source of progress information for a chapter.
+ */
+ private sealed class ProgressSource {
+ abstract val lastPageRead: Int
+ abstract val lastReadAt: Long
+
+ data class Local(
+ override val lastPageRead: Int,
+ override val lastReadAt: Long,
+ ) : ProgressSource()
+
+ data class Remote(
+ override val lastPageRead: Int,
+ override val lastReadAt: Long,
+ val device: String,
+ ) : ProgressSource()
+ }
+
+ /**
+ * Helper function to build a single OpdsEntryXml for a chapter.
+ */
+ private suspend fun buildSingleChapterMetadataEntry(
+ chapter: OpdsChapterMetadataAcqEntry,
+ manga: OpdsMangaDetails,
+ baseUrl: String,
+ locale: Locale,
+ progressSource: ProgressSource,
+ isConflict: Boolean,
+ ): OpdsEntryXml {
+ val idSuffix: String
+ val titlePrefix: String
+
+ when (progressSource) {
+ is ProgressSource.Local -> {
+ idSuffix = "" // No suffix for the primary/local entry
+ val statusKey =
+ when {
+ chapter.downloaded -> MR.strings.opds_chapter_status_downloaded
+ chapter.read -> MR.strings.opds_chapter_status_read
+ progressSource.lastPageRead > 0 -> MR.strings.opds_chapter_status_in_progress
+ else -> MR.strings.opds_chapter_status_unread
+ }
+ titlePrefix = statusKey.localized(locale)
}
- val titlePrefix = statusKey.localized(locale)
- val entryTitle = "$titlePrefix ${chapter.name}"
+ is ProgressSource.Remote -> {
+ idSuffix = ":remote"
+ titlePrefix = MR.strings.opds_chapter_status_synced.localized(locale, progressSource.device)
+ }
+ }
+
val details =
buildString {
append(MR.strings.opds_chapter_details_base.localized(locale, manga.title, chapter.name))
@@ -250,105 +332,75 @@ object OpdsEntryBuilder {
append(MR.strings.opds_chapter_details_scanlator.localized(locale, it))
}
val pageCountDisplay = chapter.pageCount.takeIf { it > 0 } ?: "?"
- append(MR.strings.opds_chapter_details_progress.localized(locale, finalLastPageRead, pageCountDisplay))
+ append(MR.strings.opds_chapter_details_progress.localized(locale, progressSource.lastPageRead, pageCountDisplay))
}
+
+ val entryTitle = "$titlePrefix ${chapter.name}"
+
val links = mutableListOf()
- var cbzFileSize: Long? = null
chapter.url?.let {
links.add(
- OpdsLinkXml(
- OpdsConstants.LINK_REL_ALTERNATE,
- it,
- "text/html",
- MR.strings.opds_linktitle_view_on_web.localized(locale),
- ),
+ OpdsLinkXml(OpdsConstants.LINK_REL_ALTERNATE, it, "text/html", MR.strings.opds_linktitle_view_on_web.localized(locale)),
)
}
if (chapter.downloaded) {
- val cbzStreamPair =
- withContext(
- Dispatchers.IO,
- ) { runCatching { ChapterDownloadHelper.getArchiveStreamWithSize(manga.id, chapter.id) }.getOrNull() }
- cbzFileSize = cbzStreamPair?.second
- cbzStreamPair?.let {
- links.add(
- OpdsLinkXml(
- OpdsConstants.LINK_REL_ACQUISITION_OPEN_ACCESS,
- "/api/v1/chapter/${chapter.id}/download?markAsRead=${serverConfig.opdsMarkAsReadOnDownload.value}",
- OpdsConstants.TYPE_CBZ,
- MR.strings.opds_linktitle_download_cbz.localized(locale),
- ),
- )
- }
+ links.add(
+ OpdsLinkXml(
+ OpdsConstants.LINK_REL_ACQUISITION_OPEN_ACCESS,
+ "/api/v1/chapter/${chapter.id}/download?markAsRead=${serverConfig.opdsMarkAsReadOnDownload.value}",
+ OpdsConstants.TYPE_CBZ,
+ MR.strings.opds_linktitle_download_cbz.localized(locale),
+ ),
+ )
}
if (chapter.pageCount > 0) {
val basePageHref =
"/api/v1/manga/${manga.id}/chapter/${chapter.sourceOrder}/page/{pageNumber}" +
"?updateProgress=${serverConfig.opdsEnablePageReadProgress.value}"
- if (showConflict) {
- // Option 1: Local progress
- val localTitleRes =
- if (localLastPageRead >
- 0
- ) {
- MR.strings.opds_linktitle_stream_pages_continue_local
- } else {
- MR.strings.opds_linktitle_stream_pages_start_local
+ val title: String =
+ when {
+ !isConflict -> {
+ val titleRes =
+ if (progressSource.lastPageRead > 0) {
+ MR.strings.opds_linktitle_stream_pages_continue
+ } else {
+ MR.strings.opds_linktitle_stream_pages_start
+ }
+ titleRes.localized(locale)
}
- links.add(
- OpdsLinkXml(
- rel = OpdsConstants.LINK_REL_PSE_STREAM,
- href = basePageHref,
- type = OpdsConstants.TYPE_IMAGE_JPEG,
- title = localTitleRes.localized(locale),
- pseCount = chapter.pageCount,
- pseLastRead = localLastPageRead.takeIf { it >= 0 },
- pseLastReadDate = chapter.lastReadAt.takeIf { it > 0 }?.let { OpdsDateUtil.formatEpochMillisForOpds(it * 1000) },
- ),
- )
- // Option 2: Remote progress
- val remoteTitleRes =
- if (remoteLastPageRead >
- 0
- ) {
- MR.strings.opds_linktitle_stream_pages_continue_remote
- } else {
- MR.strings.opds_linktitle_stream_pages_start_remote
+ progressSource is ProgressSource.Local -> {
+ val titleRes =
+ if (progressSource.lastPageRead > 0) {
+ MR.strings.opds_linktitle_stream_pages_continue_local
+ } else {
+ MR.strings.opds_linktitle_stream_pages_start_local
+ }
+ titleRes.localized(locale)
}
- links.add(
- OpdsLinkXml(
- rel = OpdsConstants.LINK_REL_PSE_STREAM,
- href = basePageHref,
- type = OpdsConstants.TYPE_IMAGE_JPEG,
- title = remoteTitleRes.localized(locale, syncResult.device),
- pseCount = chapter.pageCount,
- pseLastRead = remoteLastPageRead,
- pseLastReadDate = remoteLastReadAt?.takeIf { it > 0 }?.let { OpdsDateUtil.formatEpochMillisForOpds(it * 1000) },
- ),
- )
- } else {
- // Normal behavior: single progress link
- val titleRes =
- if (finalLastPageRead >
- 0
- ) {
- MR.strings.opds_linktitle_stream_pages_continue
- } else {
- MR.strings.opds_linktitle_stream_pages_start
+ progressSource is ProgressSource.Remote -> {
+ val titleRes =
+ if (progressSource.lastPageRead > 0) {
+ MR.strings.opds_linktitle_stream_pages_continue_remote
+ } else {
+ MR.strings.opds_linktitle_stream_pages_start_remote
+ }
+ titleRes.localized(locale, progressSource.device)
}
- links.add(
- OpdsLinkXml(
- rel = OpdsConstants.LINK_REL_PSE_STREAM,
- href = basePageHref,
- type = OpdsConstants.TYPE_IMAGE_JPEG,
- title = titleRes.localized(locale),
- pseCount = chapter.pageCount,
- pseLastRead = finalLastPageRead.takeIf { it > 0 },
- pseLastReadDate = finalLastReadAt.takeIf { it > 0 }?.let { OpdsDateUtil.formatEpochMillisForOpds(it * 1000) },
- ),
- )
- }
+ else -> "" // Should not happen
+ }
+
+ links.add(
+ OpdsLinkXml(
+ rel = OpdsConstants.LINK_REL_PSE_STREAM,
+ href = basePageHref,
+ type = OpdsConstants.TYPE_IMAGE_JPEG,
+ title = title,
+ pseCount = chapter.pageCount,
+ pseLastRead = progressSource.lastPageRead.takeIf { it > 0 },
+ pseLastReadDate = progressSource.lastReadAt.takeIf { it > 0 }?.let { OpdsDateUtil.formatEpochMillisForOpds(it * 1000) },
+ ),
+ )
links.add(
OpdsLinkXml(
rel = OpdsConstants.LINK_REL_IMAGE,
@@ -358,8 +410,18 @@ object OpdsEntryBuilder {
),
)
}
+
+ val cbzFileSize =
+ if (chapter.downloaded) {
+ withContext(Dispatchers.IO) {
+ runCatching { ChapterDownloadHelper.getArchiveStreamWithSize(manga.id, chapter.id).second }.getOrNull()
+ }
+ } else {
+ null
+ }
+
return OpdsEntryXml(
- id = "urn:suwayomi:chapter:${chapter.id}:metadata",
+ id = "urn:suwayomi:chapter:${chapter.id}:metadata$idSuffix",
title = entryTitle,
updated = OpdsDateUtil.formatEpochMillisForOpds(chapter.uploadDate),
authors =
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsFeedBuilder.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsFeedBuilder.kt
index 9d09ca1a..c1e2fbc1 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsFeedBuilder.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsFeedBuilder.kt
@@ -699,6 +699,7 @@ object OpdsFeedBuilder {
MR.strings.opds_error_chapter_not_found.localized(locale, chapterSourceOrder),
locale,
)
+
val builder =
FeedBuilderInternal(
baseUrl,
@@ -708,13 +709,29 @@ object OpdsFeedBuilder {
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
null,
)
- builder.totalResults = 1
+
mangaDetails.thumbnailUrl?.let { proxyThumbnailUrl(mangaDetails.id) }?.also {
builder.icon = it
builder.links.add(OpdsLinkXml(OpdsConstants.LINK_REL_IMAGE, it, OpdsConstants.TYPE_IMAGE_JPEG))
builder.links.add(OpdsLinkXml(OpdsConstants.LINK_REL_IMAGE_THUMBNAIL, it, OpdsConstants.TYPE_IMAGE_JPEG))
}
- builder.entries.add(OpdsEntryBuilder.createChapterMetadataEntry(chapterMetadata, mangaDetails, baseUrl, locale))
+
+ val (primaryEntry, conflictEntry) =
+ OpdsEntryBuilder.createChapterMetadataEntries(
+ chapter = chapterMetadata,
+ manga = mangaDetails,
+ baseUrl = baseUrl,
+ locale = locale,
+ )
+
+ builder.entries.add(primaryEntry)
+ if (conflictEntry != null) {
+ builder.entries.add(conflictEntry)
+ builder.totalResults = 2
+ } else {
+ builder.totalResults = 1
+ }
+
return OpdsXmlUtil.serializeFeedToString(builder.build())
}