fix(simkl): reconcile watched playback

This commit is contained in:
tapframe 2026-07-23 21:02:19 +05:30
parent 200851c6f6
commit 4f3eaee2cc
5 changed files with 386 additions and 3 deletions

View file

@ -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
}

View file

@ -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

View file

@ -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()
}
}

View file

@ -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) }
},
)
}

View file

@ -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>(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",