fix(simkl): gate sync reads by activity

This commit is contained in:
tapframe 2026-07-24 14:25:51 +05:30
parent beecece7e3
commit a69b7efd8b
2 changed files with 183 additions and 10 deletions

View file

@ -16,8 +16,11 @@ internal class SimklSyncEngine(
}
if (current.watermark == null) return initialSync()
val delta = remote.fetchAllItems(SimklAllItemsRequest.Changes(current.watermark))
var entries = mergeDelta(current.entries, delta)
var entries = current.entries
if (hasAllItemsActivityChanged(current.activities, activities)) {
val delta = remote.fetchAllItems(SimklAllItemsRequest.Changes(current.watermark))
entries = mergeDelta(entries, delta)
}
if (hasRemovalActivityChanged(current.activities, activities)) {
val authoritativeIds = remote.fetchAllItems(SimklAllItemsRequest.CurrentIds)
@ -89,19 +92,43 @@ internal fun reconcileRemovedEntries(
.sortedWith(simklEntryComparator)
}
private fun hasAllItemsActivityChanged(
previous: SimklActivities?,
current: SimklActivities,
): Boolean {
if (previous == null) return true
return SimklMediaType.entries.any { type ->
current.domain(type).hasAllItemsActivityChangedFrom(previous.domain(type))
}
}
private fun SimklActivityDomain.hasAllItemsActivityChangedFrom(
previous: SimklActivityDomain,
): Boolean =
ratedAt != previous.ratedAt ||
plantowatch != previous.plantowatch ||
watching != previous.watching ||
completed != previous.completed ||
hold != previous.hold ||
dropped != previous.dropped
private fun hasRemovalActivityChanged(
previous: SimklActivities?,
current: SimklActivities,
): Boolean = SimklMediaType.entries.any { type ->
previous?.domain(type)?.removedFromList != current.domain(type).removedFromList
}
): Boolean =
previous == null ||
SimklMediaType.entries.any { type ->
previous.domain(type).removedFromList != current.domain(type).removedFromList
}
private fun hasPlaybackActivityChanged(
previous: SimklActivities?,
current: SimklActivities,
): Boolean = SimklMediaType.entries.any { type ->
previous?.domain(type)?.playback != current.domain(type).playback
}
): Boolean =
previous == null ||
SimklMediaType.entries.any { type ->
previous.domain(type).playback != current.domain(type).playback
}
private val simklEntryComparator = compareBy<SimklLibraryEntry>(
{ entry -> entry.mediaType.ordinal },

View file

@ -78,6 +78,138 @@ class SimklSyncEngineTest {
assertTrue(remote.isExhausted)
}
@Test
fun `playback only activity refreshes playback without all items`() = runBlocking {
val current = SimklSyncSnapshot(
isInitialized = true,
watermark = "v1",
activities = activities(all = "v1", playback = "p1", library = "l1"),
entries = listOf(entry(SimklMediaType.SHOWS, "1")),
playback = listOf(playback("1")),
)
val remote = ScriptedRemote(
Step.Activities(activities(all = "v2", playback = "p2", library = "l1")),
Step.Playback(listOf(playback("2"))),
)
val result = SimklSyncEngine(remote) { 900L }.synchronize(current)
assertEquals(current.entries, result.entries)
assertEquals("2", result.playback.single().media?.ids?.idValue("simkl"))
assertEquals("v2", result.watermark)
assertTrue(remote.allItemsRequests.isEmpty())
assertTrue(remote.isExhausted)
}
@Test
fun `settings only activity updates watermark without projection calls`() = runBlocking {
val current = SimklSyncSnapshot(
isInitialized = true,
watermark = "v1",
activities = activities(all = "v1", library = "l1", settings = "s1"),
entries = listOf(entry(SimklMediaType.SHOWS, "1")),
playback = listOf(playback("1")),
)
val remote = ScriptedRemote(
Step.Activities(activities(all = "v2", library = "l1", settings = "s2")),
)
val result = SimklSyncEngine(remote) { 900L }.synchronize(current)
assertEquals(current.entries, result.entries)
assertEquals(current.playback, result.playback)
assertEquals("s2", result.activities?.settings?.all)
assertEquals("v2", result.watermark)
assertTrue(remote.allItemsRequests.isEmpty())
assertTrue(remote.isExhausted)
}
@Test
fun `removal only activity reconciles ids without delta`() = runBlocking {
val retained = entry(SimklMediaType.SHOWS, "1")
val current = SimklSyncSnapshot(
isInitialized = true,
watermark = "v1",
activities = activities(all = "v1", removed = "r1", library = "l1"),
entries = listOf(
retained,
entry(SimklMediaType.MOVIES, "2", SimklListStatus.PLAN_TO_WATCH),
),
)
val authoritative = SimklAllItemsResponse(
shows = listOf(retained),
movies = emptyList(),
anime = emptyList(),
)
val remote = ScriptedRemote(
Step.Activities(activities(all = "v2", removed = "r2", library = "l1")),
Step.AllItems(null, authoritative),
)
val result = SimklSyncEngine(remote) { 900L }.synchronize(current)
assertEquals(listOf("1"), result.entries.mapNotNull { it.media?.ids?.idValue("simkl") })
assertEquals(
listOf<SimklAllItemsRequest>(SimklAllItemsRequest.CurrentIds),
remote.allItemsRequests,
)
assertTrue(remote.isExhausted)
}
@Test
fun `each library activity timestamp requests an all items delta`() = runBlocking {
val previousDomain = SimklActivityDomain(
all = "d1",
ratedAt = "rated1",
playback = "playback",
plantowatch = "plantowatch1",
watching = "watching1",
completed = "completed1",
hold = "hold1",
dropped = "dropped1",
removedFromList = "removed",
)
val changes = listOf(
"rated_at" to previousDomain.copy(ratedAt = "rated2"),
"plantowatch" to previousDomain.copy(plantowatch = "plantowatch2"),
"watching" to previousDomain.copy(watching = "watching2"),
"completed" to previousDomain.copy(completed = "completed2"),
"hold" to previousDomain.copy(hold = "hold2"),
"dropped" to previousDomain.copy(dropped = "dropped2"),
)
changes.forEach { (name, changedDomain) ->
val previousActivities = SimklActivities(
all = "v1",
tvShows = previousDomain,
movies = previousDomain,
anime = previousDomain,
)
val changedActivities = previousActivities.copy(
all = "v2",
tvShows = changedDomain.copy(all = "d2"),
)
val current = SimklSyncSnapshot(
isInitialized = true,
watermark = "v1",
activities = previousActivities,
)
val remote = ScriptedRemote(
Step.Activities(changedActivities),
Step.AllItems(null, SimklAllItemsResponse()),
)
SimklSyncEngine(remote) { 900L }.synchronize(current)
assertEquals(
listOf<SimklAllItemsRequest>(SimklAllItemsRequest.Changes("v1")),
remote.allItemsRequests,
name,
)
assertTrue(remote.isExhausted, name)
}
}
@Test
fun `watched delta discards retained playback without fetching playback`() = runBlocking {
val retainedPlayback = episodePlayback("1", "2024-04-30T22:13:00Z")
@ -352,13 +484,27 @@ class SimklSyncEngineTest {
all: String,
removed: String = "removed",
playback: String = "playback",
library: String = all,
settings: String = "settings",
): SimklActivities {
val domain = SimklActivityDomain(
all = all,
removedFromList = removed,
ratedAt = library,
playback = playback,
plantowatch = library,
watching = library,
completed = library,
hold = library,
dropped = library,
removedFromList = removed,
)
return SimklActivities(
all = all,
settings = SimklActivitySettings(all = settings),
tvShows = domain,
movies = domain,
anime = domain,
)
return SimklActivities(all = all, tvShows = domain, movies = domain, anime = domain)
}
const val ALL_ITEMS_FIXTURE = """