diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliation.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliation.kt new file mode 100644 index 000000000..8b32da105 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliation.kt @@ -0,0 +1,26 @@ +package com.nuvio.app.features.simkl + +import com.nuvio.app.features.watched.WatchedItem +import com.nuvio.app.features.watchprogress.WatchProgressEntry + +internal fun SimklSyncSnapshot.reconcileWatchedPlayback(): SimklSyncSnapshot { + if (entries.isEmpty() || playback.isEmpty()) return this + val watchedItems = toSimklWatchedProjection().items + if (watchedItems.isEmpty()) return this + val retainedPlayback = playback.filterNot { session -> + session.toWatchProgressEntry()?.let { progress -> + watchedItems.any { watched -> watched.supersedes(progress) } + } == true + } + return if (retainedPlayback.size == playback.size) this else copy(playback = retainedPlayback) +} + +private fun WatchedItem.supersedes(progress: WatchProgressEntry): Boolean { + if (!type.equals(progress.contentType, ignoreCase = true)) return false + if (season != progress.seasonNumber || episode != progress.episodeNumber) return false + val sameProviderItem = trackingProviderItemId + ?.takeIf(String::isNotBlank) + ?.equals(progress.trackingProviderItemId, ignoreCase = true) == true + val sameContent = id.equals(progress.parentMetaId, ignoreCase = true) + return (sameProviderItem || sameContent) && markedAtEpochMs >= progress.lastUpdatedEpochMs +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt index 00b8c7e9e..57a42a9c9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt @@ -226,7 +226,7 @@ internal fun parseSimklUtcEpochMs(value: String?): Long? { return (((days * 24L + hour) * 60L + minute) * 60L + second) * 1_000L + millis } -private fun SimklPlaybackSession.toWatchProgressEntry(): WatchProgressEntry? { +internal fun SimklPlaybackSession.toWatchProgressEntry(): WatchProgressEntry? { val media = media ?: return null val parentId = media.canonicalContentId() ?: return null val isMovie = mediaType == SimklMediaType.MOVIES diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncEngine.kt index b0dbceb90..24a4f5e8c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncEngine.kt @@ -38,7 +38,7 @@ internal class SimklSyncEngine( playback = playback, lastSyncedAtEpochMs = now, lastCheckedAtEpochMs = now, - ) + ).reconcileWatchedPlayback() } private suspend fun initialSync(): SimklSyncSnapshot { @@ -61,7 +61,7 @@ internal class SimklSyncEngine( playback = playback, lastSyncedAtEpochMs = now, lastCheckedAtEpochMs = now, - ) + ).reconcileWatchedPlayback() } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliationTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliationTest.kt new file mode 100644 index 000000000..2ae9450f8 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliationTest.kt @@ -0,0 +1,283 @@ +package com.nuvio.app.features.simkl + +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class SimklPlaybackReconciliationTest { + @Test + fun `newer watched episode discards stale playback`() { + val snapshot = episodeSnapshot( + watchedAt = "2024-04-30T22:14:00Z", + pausedAt = "2024-04-30T22:13:00Z", + ) + + assertTrue(snapshot.reconcileWatchedPlayback().playback.isEmpty()) + } + + @Test + fun `equal watched timestamp discards playback`() { + val snapshot = episodeSnapshot( + watchedAt = "2024-04-30T22:13:00Z", + pausedAt = "2024-04-30T22:13:00Z", + ) + + assertTrue(snapshot.reconcileWatchedPlayback().playback.isEmpty()) + } + + @Test + fun `newer playback remains as a rewatch`() { + val snapshot = episodeSnapshot( + watchedAt = "2024-04-30T22:13:00Z", + pausedAt = "2024-04-30T22:14:00Z", + ) + + assertEquals(snapshot.playback, snapshot.reconcileWatchedPlayback().playback) + } + + @Test + fun `watched episode does not discard another episode`() { + val snapshot = SimklSyncSnapshot( + entries = listOf( + watchedEpisodeEntry( + media = media(39687, imdb = "tt4574334"), + season = 1, + episode = 5, + watchedAt = "2024-04-30T22:14:00Z", + ), + ), + playback = listOf( + episodePlayback( + playbackMedia = media(39687, imdb = "tt4574334"), + season = 1, + episode = 4, + pausedAt = "2024-04-30T22:13:00Z", + ), + ), + ) + + assertEquals(snapshot.playback, snapshot.reconcileWatchedPlayback().playback) + } + + @Test + fun `provider identity reconciles different canonical ids`() { + val snapshot = SimklSyncSnapshot( + entries = listOf( + watchedEpisodeEntry( + media = media(39687, imdb = "tt4574334"), + season = 1, + episode = 5, + watchedAt = "2024-04-30T22:14:00Z", + ), + ), + playback = listOf( + episodePlayback( + playbackMedia = media(39687, tvdb = "305288"), + season = 1, + episode = 5, + pausedAt = "2024-04-30T22:13:00Z", + ), + ), + ) + + assertEquals("tvdb:305288", snapshot.playback.single().toWatchProgressEntry()?.parentMetaId) + assertTrue(snapshot.reconcileWatchedPlayback().playback.isEmpty()) + } + + @Test + fun `mapped anime coordinates reconcile the same episode`() { + val animeMedia = media(439744, imdb = "tt2560140") + val snapshot = SimklSyncSnapshot( + entries = listOf( + SimklLibraryEntry( + mediaType = SimklMediaType.ANIME, + status = SimklListStatus.WATCHING, + show = animeMedia, + seasons = listOf( + SimklSeason( + number = 1, + episodes = listOf( + SimklEpisode( + number = 4, + watchedAt = "2024-04-30T22:14:00Z", + tvdb = SimklEpisodeMapping(season = 2, episode = 4), + ), + ), + ), + ), + ), + ), + playback = listOf( + SimklPlaybackSession( + id = 12345, + progress = 62.5, + pausedAt = "2024-04-30T22:13:00Z", + type = "episode", + episode = SimklPlaybackEpisode( + season = 1, + number = 4, + tvdbSeason = 2, + tvdbNumber = 4, + ), + anime = animeMedia, + ), + ), + ) + + assertTrue(snapshot.reconcileWatchedPlayback().playback.isEmpty()) + } + + @Test + fun `newer watched movie discards stale playback`() { + val movieMedia = media(53536, imdb = "tt0181852") + val snapshot = SimklSyncSnapshot( + entries = listOf( + SimklLibraryEntry( + mediaType = SimklMediaType.MOVIES, + status = SimklListStatus.COMPLETED, + lastWatchedAt = "2024-04-30T22:14:00Z", + movie = movieMedia, + ), + ), + playback = listOf( + SimklPlaybackSession( + id = 12345, + progress = 62.5, + pausedAt = "2024-04-30T22:13:00Z", + type = "movie", + movie = movieMedia, + ), + ), + ) + + assertTrue(snapshot.reconcileWatchedPlayback().playback.isEmpty()) + } + + @Test + fun `newer movie playback remains as a rewatch`() { + val movieMedia = media(53536, imdb = "tt0181852") + val snapshot = SimklSyncSnapshot( + entries = listOf( + SimklLibraryEntry( + mediaType = SimklMediaType.MOVIES, + status = SimklListStatus.COMPLETED, + lastWatchedAt = "2024-04-30T22:13:00Z", + movie = movieMedia, + ), + ), + playback = listOf( + SimklPlaybackSession( + id = 12345, + progress = 62.5, + pausedAt = "2024-04-30T22:14:00Z", + type = "movie", + movie = movieMedia, + ), + ), + ) + + assertEquals(snapshot.playback, snapshot.reconcileWatchedPlayback().playback) + } + + @Test + fun `completed series summary does not discard exact episode playback`() { + val showMedia = media(39687, imdb = "tt4574334") + val snapshot = SimklSyncSnapshot( + entries = listOf( + SimklLibraryEntry( + mediaType = SimklMediaType.SHOWS, + status = SimklListStatus.COMPLETED, + lastWatchedAt = "2024-04-30T22:14:00Z", + show = showMedia, + ), + ), + playback = listOf( + episodePlayback( + playbackMedia = showMedia, + season = 1, + episode = 5, + pausedAt = "2024-04-30T22:13:00Z", + ), + ), + ) + + assertEquals(snapshot.playback, snapshot.reconcileWatchedPlayback().playback) + } + + @Test + fun `snapshot without a conflict is returned unchanged`() { + val snapshot = SimklSyncSnapshot(playback = listOf(episodePlayback())) + + assertSame(snapshot, snapshot.reconcileWatchedPlayback()) + } + + private fun episodeSnapshot(watchedAt: String, pausedAt: String): SimklSyncSnapshot { + val showMedia = media(39687, imdb = "tt4574334") + return SimklSyncSnapshot( + entries = listOf( + watchedEpisodeEntry( + media = showMedia, + season = 1, + episode = 5, + watchedAt = watchedAt, + ), + ), + playback = listOf( + episodePlayback( + playbackMedia = showMedia, + season = 1, + episode = 5, + pausedAt = pausedAt, + ), + ), + ) + } + + private fun watchedEpisodeEntry( + media: SimklMedia, + season: Int, + episode: Int, + watchedAt: String, + ): SimklLibraryEntry = SimklLibraryEntry( + mediaType = SimklMediaType.SHOWS, + status = SimklListStatus.WATCHING, + show = media, + seasons = listOf( + SimklSeason( + number = season, + episodes = listOf(SimklEpisode(number = episode, watchedAt = watchedAt)), + ), + ), + ) + + private fun episodePlayback( + playbackMedia: SimklMedia = media(39687, imdb = "tt4574334"), + season: Int = 1, + episode: Int = 5, + pausedAt: String = "2024-04-30T22:13:00Z", + ): SimklPlaybackSession = SimklPlaybackSession( + id = 12345, + progress = 62.5, + pausedAt = pausedAt, + type = "episode", + episode = SimklPlaybackEpisode(season = season, number = episode), + show = playbackMedia, + ) + + private fun media( + id: Long, + imdb: String? = null, + tvdb: String? = null, + ): SimklMedia = SimklMedia( + title = "Title $id", + runtime = 50, + ids = buildJsonObject { + put("simkl", id) + imdb?.let { put("imdb", it) } + tvdb?.let { put("tvdb", it) } + }, + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSyncEngineTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSyncEngineTest.kt index f9e0d3ab9..0891632aa 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSyncEngineTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSyncEngineTest.kt @@ -78,6 +78,60 @@ class SimklSyncEngineTest { assertTrue(remote.isExhausted) } + @Test + fun `watched delta discards retained playback without fetching playback`() = runBlocking { + val retainedPlayback = episodePlayback("1", "2024-04-30T22:13:00Z") + val current = SimklSyncSnapshot( + isInitialized = true, + watermark = "v1", + activities = activities(all = "v1", playback = "p1"), + entries = listOf(entry(SimklMediaType.SHOWS, "1")), + playback = listOf(retainedPlayback), + ) + val remote = ScriptedRemote( + Step.Activities(activities(all = "v2", playback = "p1")), + Step.AllItems( + null, + responseOf(watchedShowEntry("1", "2024-04-30T22:14:00Z")), + ), + ) + + val result = SimklSyncEngine(remote) { 900L }.synchronize(current) + + assertTrue(result.playback.isEmpty()) + assertTrue(result.toSimklProgressEntries().isEmpty()) + assertTrue( + result.toSimklWatchedProjection().items.any { watched -> + watched.season == 1 && watched.episode == 5 + }, + ) + assertEquals( + listOf(SimklAllItemsRequest.Changes("v1")), + remote.allItemsRequests, + ) + assertTrue(remote.isExhausted) + } + + @Test + fun `initial sync discards playback superseded by watched history`() = runBlocking { + val remote = ScriptedRemote( + Step.AllItems( + SimklMediaType.SHOWS, + responseOf(watchedShowEntry("1", "2024-04-30T22:14:00Z")), + ), + Step.AllItems(SimklMediaType.MOVIES, SimklAllItemsResponse(movies = emptyList())), + Step.AllItems(SimklMediaType.ANIME, SimklAllItemsResponse(anime = emptyList())), + Step.Playback(listOf(episodePlayback("1", "2024-04-30T22:13:00Z"))), + Step.Activities(activities(all = "v1", playback = "p1")), + ) + + val result = SimklSyncEngine(remote) { 900L }.synchronize(SimklSyncSnapshot()) + + assertTrue(result.playback.isEmpty()) + assertEquals(1, result.toSimklWatchedProjection().items.size) + assertTrue(remote.isExhausted) + } + @Test fun `delta merge reconciles removals and replaces changed playback atomically`() = runBlocking { val previousActivities = activities(all = "v1", removed = "r1", playback = "p1") @@ -274,6 +328,26 @@ class SimklSyncEngineTest { movie = media(id), ) + fun episodePlayback(id: String, pausedAt: String) = SimklPlaybackSession( + id = id.toLong(), + progress = 62.5, + pausedAt = pausedAt, + type = "episode", + episode = SimklPlaybackEpisode(season = 1, number = 5), + show = media(id), + ) + + fun watchedShowEntry(id: String, watchedAt: String) = + entry(SimklMediaType.SHOWS, id).copy( + lastWatchedAt = watchedAt, + seasons = listOf( + SimklSeason( + number = 1, + episodes = listOf(SimklEpisode(number = 5, watchedAt = watchedAt)), + ), + ), + ) + fun activities( all: String, removed: String = "removed",