diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklAnimeWatchedResolutionTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklAnimeWatchedResolutionTest.kt index f11f27fb2..2db3df537 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklAnimeWatchedResolutionTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklAnimeWatchedResolutionTest.kt @@ -227,8 +227,8 @@ class SimklAnimeWatchedResolutionTest { val resolved = reference.resolveAnimeEpisodeForSimkl() assertEquals(42203L, resolved.ids.mal) - assertEquals("tt2560140", resolved.ids.imdb) - assertEquals(39687L, resolved.ids.simkl) + assertNull(resolved.ids.imdb) + assertNull(resolved.ids.simkl) assertNull(resolved.episode?.season) assertEquals(7, resolved.episode?.number) } @@ -250,7 +250,7 @@ class SimklAnimeWatchedResolutionTest { val resolved = reference.resolveAnimeEpisodeForSimkl() assertEquals(12268L, resolved.ids.kitsu) - assertEquals("tt5311514", resolved.ids.imdb) + assertNull(resolved.ids.imdb) assertNull(resolved.episode?.season) assertEquals(3, resolved.episode?.number) } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt index 37f7a895e..5f0833c90 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt @@ -153,13 +153,16 @@ class SimklMutationRepositoryTest { } @Test - fun `service reports partial not found and treats duplicate stop as committed`() = runBlocking { + fun `service reports partial not found and returns duplicate stop as scrobbled`() = runBlocking { val engine = RecordingEngine( response( status = 201, body = """{"added":{"movies":[{"to":"completed"}]},"not_found":{"movies":[{"title":"Missing"}],"shows":[]}}""", ), - response(status = 409), + response( + status = 409, + body = """{"watched_at":"2026-05-14T23:46:29Z","expires_at":"2026-05-15T00:46:29Z"}""", + ), ) var now = 0L var committed = 0 @@ -179,7 +182,7 @@ class SimklMutationRepositoryTest { items = listOf(movie(), movie().copy(title = "Missing", ids = TrackingExternalIds())), destination = TrackingListStatus.PLAN_TO_WATCH, ) - service.scrobble( + val scrobbleResult = service.scrobble( action = TrackingScrobbleAction.STOP, event = TrackingScrobbleEvent(movie(), progressPercent = 90.0), ) @@ -189,7 +192,9 @@ class SimklMutationRepositoryTest { assertEquals(listOf(TrackingListStatus.COMPLETED), result.resolvedListStatuses) assertFalse(result.isComplete) assertEquals(listOf("/sync/add-to-list", "/scrobble/stop"), engine.paths) - assertEquals(2, committed) + assertEquals(SimklScrobbleOutcome.SCROBBLE, scrobbleResult.outcome) + assertEquals("2026-05-14T23:46:29Z", scrobbleResult.watchedAt) + assertEquals(1, committed) } @Test @@ -268,6 +273,72 @@ class SimklMutationRepositoryTest { assertEquals(listOf("/scrobble/pause"), engine.paths) } + @Test + fun `successful pause returns local reconciliation data without invalidating sync`() = runBlocking { + val engine = RecordingEngine( + response( + status = 201, + body = """{"id":42,"action":"pause","progress":45,"movie":{"title":"Terminator 3: Rise of the Machines","year":2003,"ids":{"simkl":53536}}}""", + ), + ) + var committed = 0 + val service = SimklMutationService( + client = SimklApiClient( + engine = engine, + accessToken = { "token" }, + onUnauthorized = {}, + nowEpochMs = { 0L }, + sleep = {}, + retryJitterMs = { 0L }, + ), + onMutationCommitted = { committed += 1 }, + ) + + val result = service.scrobble( + action = TrackingScrobbleAction.PAUSE, + event = TrackingScrobbleEvent(movie(), progressPercent = 45.0), + ) + + assertEquals(listOf("/scrobble/pause"), engine.paths) + assertEquals(SimklScrobbleOutcome.PAUSE, result.outcome) + assertEquals(42L, result.playbackId) + assertEquals(45.0, result.progress) + assertEquals(0, committed) + } + + @Test + fun `low progress stop returns a paused playback without invalidating sync`() = runBlocking { + val engine = RecordingEngine( + response( + status = 201, + body = """{"id":42,"action":"pause","progress":30,"movie":{"title":"Terminator 3: Rise of the Machines","year":2003,"ids":{"simkl":53536}}}""", + ), + ) + var committed = 0 + val service = SimklMutationService( + client = SimklApiClient( + engine = engine, + accessToken = { "token" }, + onUnauthorized = {}, + nowEpochMs = { 0L }, + sleep = {}, + retryJitterMs = { 0L }, + ), + onMutationCommitted = { committed += 1 }, + ) + + val result = service.scrobble( + action = TrackingScrobbleAction.STOP, + event = TrackingScrobbleEvent(movie(), progressPercent = 30.0), + ) + + assertEquals(listOf("/scrobble/stop"), engine.paths) + assertEquals(SimklScrobbleOutcome.PAUSE, result.outcome) + assertEquals(42L, result.playbackId) + assertEquals(30.0, result.progress) + assertEquals(0, committed) + } + private fun String.asObject() = json.parseToJsonElement(this).jsonObject private fun movie() = TrackingMediaReference( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklScrobbleReconciliationTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklScrobbleReconciliationTest.kt new file mode 100644 index 000000000..07f07c574 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklScrobbleReconciliationTest.kt @@ -0,0 +1,218 @@ +package com.nuvio.app.features.simkl + +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SimklScrobbleReconciliationTest { + @Test + fun `pause replaces local playback without advancing the sync watermark`() { + val media = showMedia(simklId = 2090L, imdb = "tt1520211") + val snapshot = SimklSyncSnapshot( + isInitialized = true, + watermark = "v1", + entries = listOf( + SimklLibraryEntry( + mediaType = SimklMediaType.SHOWS, + status = SimklListStatus.WATCHING, + show = media, + ), + ), + playback = listOf( + SimklPlaybackSession( + id = 4L, + progress = 20.0, + pausedAt = "2023-11-14T21:00:00Z", + type = "episode", + episode = SimklPlaybackEpisode(season = 1, number = 2), + show = media, + ), + ), + ) + + val updated = snapshot.applyScrobbleResult( + result = SimklScrobbleResult( + outcome = SimklScrobbleOutcome.PAUSE, + playbackId = 8L, + progress = 45.0, + mediaType = SimklMediaType.SHOWS, + media = media, + episode = SimklPlaybackEpisode(season = 1, number = 2), + ), + committedAtEpochMs = 1_700_000_000_000L, + ) + + assertEquals("v1", updated.watermark) + assertEquals(1, updated.playback.size) + assertEquals(8L, updated.playback.single().id) + assertEquals(45.0, updated.playback.single().progress) + assertEquals("2023-11-14T22:13:20Z", updated.playback.single().pausedAt) + assertTrue(updated.toSimklWatchedProjection().items.isEmpty()) + } + + @Test + fun `completed stop records the exact episode and removes its playback`() { + val media = showMedia(simklId = 2090L, imdb = "tt1520211") + val snapshot = SimklSyncSnapshot( + isInitialized = true, + watermark = "v1", + entries = listOf( + SimklLibraryEntry( + mediaType = SimklMediaType.SHOWS, + status = SimklListStatus.WATCHING, + show = media, + seasons = listOf( + SimklSeason( + number = 1, + episodes = listOf(SimklEpisode(number = 2)), + ), + ), + ), + ), + playback = listOf( + SimklPlaybackSession( + id = 8L, + progress = 70.0, + pausedAt = "2023-11-14T21:00:00Z", + type = "episode", + episode = SimklPlaybackEpisode(season = 1, number = 2), + show = media, + ), + ), + ) + + val updated = snapshot.applyScrobbleResult( + result = SimklScrobbleResult( + outcome = SimklScrobbleOutcome.SCROBBLE, + playbackId = 9L, + progress = 90.0, + mediaType = SimklMediaType.SHOWS, + media = media, + episode = SimklPlaybackEpisode(season = 1, number = 2), + ), + committedAtEpochMs = 1_700_000_000_000L, + ) + + assertEquals("v1", updated.watermark) + assertTrue(updated.playback.isEmpty()) + assertEquals(1, updated.entries.single().watchedEpisodesCount) + val watched = updated.toSimklWatchedProjection().items.single() + assertEquals(1, watched.season) + assertEquals(2, watched.episode) + assertEquals(1_700_000_000_000L, watched.markedAtEpochMs) + } + + @Test + fun `anime stop updates only the matching Simkl title when siblings share IMDb`() { + val firstMedia = animeMedia(100L, "tt2560140", 16498L) + val secondMedia = animeMedia(101L, "tt2560140", 25777L) + val snapshot = SimklSyncSnapshot( + entries = listOf( + animeEntry(firstMedia, season = 1, episode = 3), + animeEntry(secondMedia, season = 1, episode = 3, tvdbSeason = 2), + ), + ) + + val updated = snapshot.applyScrobbleResult( + result = SimklScrobbleResult( + outcome = SimklScrobbleOutcome.SCROBBLE, + playbackId = 12L, + progress = 95.0, + mediaType = SimklMediaType.ANIME, + media = secondMedia, + episode = SimklPlaybackEpisode( + season = 1, + number = 3, + tvdbSeason = 2, + tvdbNumber = 3, + ), + ), + committedAtEpochMs = 1_700_000_000_000L, + ) + + val first = updated.entries.first { it.media?.ids?.simklIdValue() == "100" } + val second = updated.entries.first { it.media?.ids?.simklIdValue() == "101" } + assertNull(first.seasons.single().episodes.single().watchedAt) + assertEquals( + "2023-11-14T22:13:20Z", + second.seasons.single().episodes.single().watchedAt, + ) + val watched = updated.toSimklWatchedProjection().items.single() + assertEquals(2, watched.season) + assertEquals(3, watched.episode) + } + + @Test + fun `completed movie stop creates watched state without an activities refresh`() { + val media = SimklMedia( + title = "Inception", + year = 2010, + ids = mapOf( + "simkl" to JsonPrimitive(472214L), + "imdb" to JsonPrimitive("tt1375666"), + ), + ) + + val updated = SimklSyncSnapshot().applyScrobbleResult( + result = SimklScrobbleResult( + outcome = SimklScrobbleOutcome.SCROBBLE, + playbackId = 15L, + progress = 95.0, + mediaType = SimklMediaType.MOVIES, + media = media, + episode = null, + watchedAt = "2023-11-14T21:00:00Z", + ), + committedAtEpochMs = 1_700_000_000_000L, + ) + + assertEquals(SimklListStatus.COMPLETED, updated.entries.single().status) + assertEquals("2023-11-14T21:00:00Z", updated.entries.single().lastWatchedAt) + assertEquals("tt1375666", updated.toSimklWatchedProjection().items.single().id) + } + + private fun showMedia(simklId: Long, imdb: String) = SimklMedia( + title = "The Walking Dead", + year = 2010, + ids = mapOf( + "simkl" to JsonPrimitive(simklId), + "imdb" to JsonPrimitive(imdb), + ), + ) + + private fun animeMedia(simklId: Long, imdb: String, mal: Long) = SimklMedia( + title = "Attack on Titan", + year = 2013, + ids = mapOf( + "simkl" to JsonPrimitive(simklId), + "imdb" to JsonPrimitive(imdb), + "mal" to JsonPrimitive(mal), + ), + ) + + private fun animeEntry( + media: SimklMedia, + season: Int, + episode: Int, + tvdbSeason: Int? = null, + ) = SimklLibraryEntry( + mediaType = SimklMediaType.ANIME, + status = SimklListStatus.WATCHING, + show = media, + seasons = listOf( + SimklSeason( + number = season, + episodes = listOf( + SimklEpisode( + number = episode, + tvdb = tvdbSeason?.let { value -> + SimklEpisodeMapping(value, episode) + }, + ), + ), + ), + ), + ) +} 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 74836c1d5..981ff3836 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 @@ -359,7 +359,7 @@ class SimklSyncEngineTest { assertTrue("extended=full" in urls[0]) assertTrue("episode_watched_at=yes" in urls[0]) assertTrue("include_all_episodes=yes" in urls[0]) - assertFalse("episode_tvdb_id=" in urls[0]) + assertTrue("episode_tvdb_id=yes" in urls[0]) assertTrue("date_from=2026-05-08T14%3A23%3A11Z" in urls[1]) assertTrue("extended=full_anime_seasons" in urls[1]) assertTrue("episode_watched_at=yes" in urls[1])