From 4b6a15c950cd799cb3ca177c2c2c926dec79db39 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:59:53 +0530 Subject: [PATCH] Fix Trakt progress watched precedence --- .../features/trakt/TraktProgressRepository.kt | 14 +--- .../watchprogress/WatchProgressRules.kt | 19 +++++ .../watchprogress/WatchProgressRulesTest.kt | 70 +++++++++++++++++++ 3 files changed, 90 insertions(+), 13 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt index 35eab018f..dcf6a2093 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt @@ -11,7 +11,7 @@ import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktHistory import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktShowProgress import com.nuvio.app.features.watchprogress.buildPlaybackVideoId -import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching +import com.nuvio.app.features.watchprogress.shouldReplaceProgressSnapshotEntry import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred @@ -1007,18 +1007,6 @@ object TraktProgressRepository { .sortedByDescending { it.lastUpdatedEpochMs } } - private fun shouldReplaceProgressSnapshotEntry( - existing: WatchProgressEntry, - candidate: WatchProgressEntry, - ): Boolean { - val existingInProgress = existing.shouldTreatAsInProgressForContinueWatching() - val candidateInProgress = candidate.shouldTreatAsInProgressForContinueWatching() - if (existingInProgress != candidateInProgress) { - return candidateInProgress - } - return candidate.lastUpdatedEpochMs > existing.lastUpdatedEpochMs - } - private fun mergeEntriesPreferRichMetadata( current: List, hydrated: List, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt index bcaedb77c..dfd1cd8d2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt @@ -13,6 +13,7 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json internal const val ContinueWatchingLimit = DefaultContinueWatchingLimit +private const val WatchProgressSnapshotConflictToleranceMs = 1_000L @Serializable internal data class StoredWatchProgressPayload( @@ -124,6 +125,24 @@ internal fun WatchProgressEntry.shouldUseAsCompletedSeedForContinueWatching(): B return explicitPercent >= WatchProgressTraktPlaybackNextUpSeedPercentThreshold } +internal fun shouldReplaceProgressSnapshotEntry( + existing: WatchProgressEntry, + candidate: WatchProgressEntry, +): Boolean { + val normalizedExisting = existing.normalizedCompletion() + val normalizedCandidate = candidate.normalizedCompletion() + val existingInProgress = normalizedExisting.shouldTreatAsInProgressForContinueWatching() + val candidateInProgress = normalizedCandidate.shouldTreatAsInProgressForContinueWatching() + if (existingInProgress != candidateInProgress) { + val inProgressEntry = if (candidateInProgress) normalizedCandidate else normalizedExisting + val completedEntry = if (candidateInProgress) normalizedExisting else normalizedCandidate + val inProgressIsCurrentEnough = + inProgressEntry.lastUpdatedEpochMs >= completedEntry.lastUpdatedEpochMs - WatchProgressSnapshotConflictToleranceMs + return if (candidateInProgress) inProgressIsCurrentEnough else !inProgressIsCurrentEnough + } + return normalizedCandidate.lastUpdatedEpochMs > normalizedExisting.lastUpdatedEpochMs +} + internal fun shouldCascadeCompletedProgressToWatchedHistory( entry: WatchProgressEntry, isUsingTraktProgress: Boolean, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt index eefc7b906..73d5e8c4e 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt @@ -191,6 +191,76 @@ class WatchProgressRulesTest { assertFalse(history.shouldTreatAsInProgressForContinueWatching()) } + @Test + fun `Trakt playback does not replace watched history when watched timestamp is newer`() { + val watched = entry( + videoId = "show:1:4", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 3_000L, + isCompleted = true, + progressPercent = 100f, + source = WatchProgressSourceTraktHistory, + ) + val playback = watched.copy( + lastUpdatedEpochMs = 1_000L, + isCompleted = false, + progressPercent = 25f, + source = WatchProgressSourceTraktPlayback, + ) + + assertFalse(shouldReplaceProgressSnapshotEntry(existing = watched, candidate = playback)) + assertTrue(shouldReplaceProgressSnapshotEntry(existing = playback, candidate = watched)) + } + + @Test + fun `Trakt playback replaces watched history when playback timestamp is newer`() { + val watched = entry( + videoId = "show:1:4", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 1_000L, + isCompleted = true, + progressPercent = 100f, + source = WatchProgressSourceTraktHistory, + ) + val playback = watched.copy( + lastUpdatedEpochMs = 3_000L, + isCompleted = false, + progressPercent = 25f, + source = WatchProgressSourceTraktPlayback, + ) + + assertTrue(shouldReplaceProgressSnapshotEntry(existing = watched, candidate = playback)) + assertFalse(shouldReplaceProgressSnapshotEntry(existing = playback, candidate = watched)) + } + + @Test + fun `Trakt playback uses TV timestamp tolerance against watched history`() { + val watched = entry( + videoId = "show:1:4", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 2_000L, + isCompleted = true, + progressPercent = 100f, + source = WatchProgressSourceTraktHistory, + ) + val insideTolerance = watched.copy( + lastUpdatedEpochMs = 1_001L, + isCompleted = false, + progressPercent = 25f, + source = WatchProgressSourceTraktPlayback, + ) + val outsideTolerance = insideTolerance.copy(lastUpdatedEpochMs = 999L) + + assertTrue(shouldReplaceProgressSnapshotEntry(existing = watched, candidate = insideTolerance)) + assertFalse(shouldReplaceProgressSnapshotEntry(existing = watched, candidate = outsideTolerance)) + } + @Test fun `completed progress does not cascade to watched history while Trakt progress is active`() { val completed = entry(