From e96895345ecb1e12f6e40e30987d9695eb76e914 Mon Sep 17 00:00:00 2001 From: KaiserBh <41852205+kaiserbh@users.noreply.github.com> Date: Tue, 7 Apr 2026 03:08:30 +1000 Subject: [PATCH] feat(sync): prevent deleted "ghost chapters" from reappearing during sync. (#1575) * feat(sync): prevent deleted "ghost chapters" from reappearing during sync. - Pass lastSyncTime down to mergeChapters in SyncService.kt. - Apply timestamp-based tombstoning logic to chapter merging. When a chapter is missing from either the local or remote backup, its `lastModifiedAt` timestamp is checked against the device's last sync time. - Ensure that chapters deleted on one device (or removed by a source) are recognized as deletions and dropped from the merged backup, rather than being erroneously restored as "new" chapters on subsequent syncs. * chore: change timestamp to use duration-based calculations * chore: spotless --- .../data/sync/service/SyncService.kt | 50 ++++++++++++++++--- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncService.kt index 84a51d0ca..7b5509643 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/sync/service/SyncService.kt @@ -14,6 +14,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import logcat.LogPriority import logcat.logcat +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @Serializable @@ -135,14 +136,31 @@ abstract class SyncService( "Starting merge. Local list size: ${localMangaListSafe.size}, Remote list size: ${remoteMangaListSafe.size}" } + val lastSyncTime = syncPreferences.lastSyncTimestamp().get().milliseconds.inWholeSeconds + val syncOptions = syncPreferences.getSyncSettings() + val mergedList = (localMangaMap.keys + remoteMangaMap.keys).distinct().mapNotNull { compositeKey -> val local = localMangaMap[compositeKey] val remote = remoteMangaMap[compositeKey] // New version comparison logic when { - local != null && remote == null -> updateCategories(local, localCategoriesMapByOrder) - local == null && remote != null -> updateCategories(remote, remoteCategoriesMapByOrder) + local != null && remote == null -> { + if (lastSyncTime == 0L || local.lastModifiedAt > lastSyncTime) { + updateCategories(local, localCategoriesMapByOrder) + } else { + logcat(LogPriority.DEBUG, logTag) { "Dropping local manga deleted on remote: ${local.title}." } + null + } + } + local == null && remote != null -> { + if (lastSyncTime == 0L || remote.lastModifiedAt > lastSyncTime) { + updateCategories(remote, remoteCategoriesMapByOrder) + } else { + logcat(LogPriority.DEBUG, logTag) { "Dropping deleted remote manga: ${remote.title}." } + null + } + } local != null && remote != null -> { // Compare versions to decide which manga to keep if (local.version >= remote.version) { @@ -150,7 +168,7 @@ abstract class SyncService( "Keeping local version of ${local.title} with merged chapters." } updateCategories( - local.copy(chapters = mergeChapters(local.chapters, remote.chapters)), + local.copy(chapters = mergeChapters(local.chapters, remote.chapters, lastSyncTime, syncOptions.chapters)), localCategoriesMapByOrder, ) } else { @@ -158,7 +176,7 @@ abstract class SyncService( "Keeping remote version of ${remote.title} with merged chapters." } updateCategories( - remote.copy(chapters = mergeChapters(local.chapters, remote.chapters)), + remote.copy(chapters = mergeChapters(local.chapters, remote.chapters, lastSyncTime, syncOptions.chapters)), remoteCategoriesMapByOrder, ) } @@ -198,9 +216,15 @@ abstract class SyncService( private fun mergeChapters( localChapters: List, remoteChapters: List, + lastSyncTime: Long, + syncingChapters: Boolean, ): List { val logTag = "MergeChapters" + if (!syncingChapters) { + return remoteChapters // If not syncing chapters, keep remote untouched + } + fun chapterCompositeKey(chapter: BackupChapter): String { return "${chapter.url}|${chapter.name}|${chapter.chapterNumber}" } @@ -224,12 +248,22 @@ abstract class SyncService( when { localChapter != null && remoteChapter == null -> { - logcat(LogPriority.DEBUG, logTag) { "Keeping local chapter: ${localChapter.name}." } - localChapter + if (lastSyncTime == 0L || localChapter.lastModifiedAt > lastSyncTime) { + logcat(LogPriority.DEBUG, logTag) { "Keeping local chapter: ${localChapter.name}." } + localChapter + } else { + logcat(LogPriority.DEBUG, logTag) { "Dropping local chapter deleted on remote: ${localChapter.name}." } + null + } } localChapter == null && remoteChapter != null -> { - logcat(LogPriority.DEBUG, logTag) { "Taking remote chapter: ${remoteChapter.name}." } - remoteChapter + if (lastSyncTime == 0L || remoteChapter.lastModifiedAt > lastSyncTime) { + logcat(LogPriority.DEBUG, logTag) { "Taking remote chapter: ${remoteChapter.name}." } + remoteChapter + } else { + logcat(LogPriority.DEBUG, logTag) { "Dropping deleted remote chapter: ${remoteChapter.name}." } + null + } } localChapter != null && remoteChapter != null -> { // Use version number to decide which chapter to keep