From e408a2f48caa556a97adf72ad389096f9f1caa24 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:18:30 +0530 Subject: [PATCH] fix(progress): isolate continue watching sources --- .../app/features/tracking/TrackingReads.kt | 1 - .../trakt/TraktTrackingProgressProvider.kt | 3 - .../watchprogress/WatchProgressRepository.kt | 40 +++---------- .../WatchProgressSourceProjection.kt | 13 +++++ .../WatchProgressIdentityTest.kt | 30 ---------- .../WatchProgressSourceProjectionTest.kt | 58 +++++++++++++++++++ 6 files changed, 79 insertions(+), 66 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceProjection.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceProjectionTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt index cf4be3f7b..373109b35 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt @@ -139,7 +139,6 @@ interface TrackingProgressProvider { fun applyOptimisticRemoval(entries: Collection) = Unit fun applyOptimisticProgress(entry: WatchProgressEntry) = Unit fun normalizeParentContentId(parentContentId: String, videoId: String?): String = parentContentId - fun shouldRetainLocalEntry(entry: WatchProgressEntry): Boolean = true suspend fun refreshEpisodeProgress(contentId: String, forceRefresh: Boolean) = Unit fun isHiddenFromProgress(contentId: String): Boolean = false } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingProgressProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingProgressProvider.kt index c0805b337..17607054f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingProgressProvider.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingProgressProvider.kt @@ -131,9 +131,6 @@ object TraktTrackingProgressProvider : TrackingProgressProvider { override fun normalizeParentContentId(parentContentId: String, videoId: String?): String = resolveEffectiveContentId(parentContentId, videoId) - override fun shouldRetainLocalEntry(entry: WatchProgressEntry): Boolean = - !isTraktCompatibleId(entry.parentMetaId) - override suspend fun refreshEpisodeProgress(contentId: String, forceRefresh: Boolean) = TraktProgressRepository.refreshEpisodeProgress(contentId, forceRefresh) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index 37ab0534b..825957abb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -68,34 +68,6 @@ private data class MetadataProviderReadiness( get() = providers.isNotEmpty() } -internal fun mergeTrackerProgressEntries( - remoteEntries: Collection, - localEntries: Collection, -): List { - val newestByMedia = linkedMapOf() - remoteEntries.forEach { entry -> - newestByMedia[entry.trackerMediaIdentity()] = entry - } - localEntries.forEach { entry -> - val key = entry.trackerMediaIdentity() - val existing = newestByMedia[key] - if (existing == null || entry.lastUpdatedEpochMs > existing.lastUpdatedEpochMs) { - newestByMedia[key] = entry - } - } - return newestByMedia.values.toList() -} - -private fun WatchProgressEntry.trackerMediaIdentity(): String = buildString { - append(parentMetaType.trim().lowercase()) - append(':') - append(parentMetaId.trim().lowercase()) - append(':') - append(seasonNumber ?: -1) - append(':') - append(episodeNumber ?: -1) -} - internal class MetadataResolutionRetryCoordinator { private val lock = SynchronizedObject() private var generation = 0L @@ -1489,10 +1461,14 @@ object WatchProgressRepository { } private fun currentEntries(): List { - val provider = activeProgressProvider() ?: return localEntriesSnapshot() - return mergeTrackerProgressEntries( - remoteEntries = provider.snapshot().entries, - localEntries = localEntriesSnapshot().filter(provider::shouldRetainLocalEntry), + val providerEntries = activeProgressProvider() + ?.snapshot() + ?.entries + .orEmpty() + return projectWatchProgressSourceEntries( + source = activeSource, + nuvioEntries = localEntriesSnapshot(), + providerEntries = providerEntries, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceProjection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceProjection.kt new file mode 100644 index 000000000..121ae3eaa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceProjection.kt @@ -0,0 +1,13 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.features.tracking.WatchProgressSource + +internal fun projectWatchProgressSourceEntries( + source: WatchProgressSource, + nuvioEntries: Collection, + providerEntries: Collection, +): List = if (source.providerId == null) { + nuvioEntries.toList() +} else { + providerEntries.toList() +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt index a75017e71..39003816f 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt @@ -9,36 +9,6 @@ import kotlin.test.assertTrue class WatchProgressIdentityTest { - @Test - fun `tracker progress merge keeps one entry per media and favors fresher local playback`() { - val remote = entry( - progressKey = "simkl-playback:42", - lastUpdatedEpochMs = 100L, - lastPositionMs = 400L, - ) - val staleLocal = entry(lastUpdatedEpochMs = 90L, lastPositionMs = 300L) - val otherLocal = entry( - parentMetaId = "other", - videoId = "other:1:2", - lastUpdatedEpochMs = 80L, - ) - - val firstMerge = mergeTrackerProgressEntries( - remoteEntries = listOf(remote), - localEntries = listOf(staleLocal, otherLocal), - ) - - assertEquals(2, firstMerge.size) - assertEquals("simkl-playback:42", firstMerge.single { it.parentMetaId == "show" }.progressKey) - - val freshLocal = staleLocal.copy(lastUpdatedEpochMs = 110L, lastPositionMs = 500L) - val secondMerge = mergeTrackerProgressEntries( - remoteEntries = listOf(remote), - localEntries = listOf(freshLocal), - ) - assertEquals(500L, secondMerge.single().lastPositionMs) - } - @Test fun `provider change during metadata batch schedules one follow up`() { val coordinator = MetadataResolutionRetryCoordinator() diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceProjectionTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceProjectionTest.kt new file mode 100644 index 000000000..c58747cc5 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceProjectionTest.kt @@ -0,0 +1,58 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.features.tracking.WatchProgressSource +import kotlin.test.Test +import kotlin.test.assertEquals + +class WatchProgressSourceProjectionTest { + @Test + fun `remote source excludes every Nuvio progress entry`() { + val nuvioEntries = listOf( + entry(parentMetaId = "shared", updatedAt = 200L), + entry(parentMetaId = "nuvio-only", updatedAt = 300L), + ) + val providerEntries = listOf( + entry(parentMetaId = "shared", updatedAt = 100L), + ) + + listOf(WatchProgressSource.TRAKT, WatchProgressSource.SIMKL).forEach { source -> + val projected = projectWatchProgressSourceEntries( + source = source, + nuvioEntries = nuvioEntries, + providerEntries = providerEntries, + ) + + assertEquals(providerEntries, projected) + } + } + + @Test + fun `Nuvio source excludes every provider progress entry`() { + val nuvioEntries = listOf(entry(parentMetaId = "nuvio")) + val providerEntries = listOf(entry(parentMetaId = "provider")) + + val projected = projectWatchProgressSourceEntries( + source = WatchProgressSource.NUVIO_SYNC, + nuvioEntries = nuvioEntries, + providerEntries = providerEntries, + ) + + assertEquals(nuvioEntries, projected) + } + + private fun entry( + parentMetaId: String, + updatedAt: Long = 1L, + ): WatchProgressEntry = WatchProgressEntry( + contentType = "series", + parentMetaId = parentMetaId, + parentMetaType = "series", + videoId = "$parentMetaId:1:1", + title = parentMetaId, + seasonNumber = 1, + episodeNumber = 1, + lastPositionMs = 10L, + durationMs = 100L, + lastUpdatedEpochMs = updatedAt, + ) +}