fix(simkl): fetch historical episode state

This commit is contained in:
tapframe 2026-07-22 22:06:26 +05:30
parent 002a54e022
commit b4eba6e0c5
3 changed files with 62 additions and 45 deletions

View file

@ -16,18 +16,11 @@ internal class SimklSyncEngine(
}
if (current.watermark == null) return initialSync()
val delta = remote.fetchAllItems(
SimklAllItemsRequest(
dateFrom = current.watermark,
includeEpisodeDetails = true,
),
)
val delta = remote.fetchAllItems(SimklAllItemsRequest.Changes(current.watermark))
var entries = mergeDelta(current.entries, delta)
if (hasRemovalActivityChanged(current.activities, activities)) {
val authoritativeIds = remote.fetchAllItems(
SimklAllItemsRequest(idsOnly = true),
)
val authoritativeIds = remote.fetchAllItems(SimklAllItemsRequest.CurrentIds)
entries = reconcileRemovedEntries(entries, authoritativeIds)
}
@ -52,7 +45,7 @@ internal class SimklSyncEngine(
val entries = buildList {
SimklMediaType.entries.forEach { type ->
addAll(
remote.fetchAllItems(SimklAllItemsRequest(type = type))
remote.fetchAllItems(SimklAllItemsRequest.Bootstrap(type))
.entriesFor(type),
)
}

View file

@ -6,12 +6,23 @@ import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.decodeFromJsonElement
internal data class SimklAllItemsRequest(
val type: SimklMediaType? = null,
val dateFrom: String? = null,
val includeEpisodeDetails: Boolean = false,
val idsOnly: Boolean = false,
)
internal sealed interface SimklAllItemsRequest {
val type: SimklMediaType?
data class Bootstrap(
override val type: SimklMediaType,
) : SimklAllItemsRequest
data class Changes(
val dateFrom: String,
) : SimklAllItemsRequest {
override val type: SimklMediaType? = null
}
data object CurrentIds : SimklAllItemsRequest {
override val type: SimklMediaType? = null
}
}
internal interface SimklSyncRemote {
suspend fun fetchActivities(): SimklActivities
@ -38,17 +49,22 @@ internal class SimklApiSyncRemote(
override suspend fun fetchAllItems(request: SimklAllItemsRequest): SimklAllItemsResponse {
val path = request.type?.let { type -> "/sync/all-items/${type.apiValue}" }
?: "/sync/all-items"
val query = buildMap {
request.dateFrom?.let { value -> put("date_from", value) }
when {
request.idsOnly -> put("extended", "simkl_ids_only")
request.includeEpisodeDetails -> {
put("extended", "full_anime_seasons")
put("episode_watched_at", "yes")
put("episode_tvdb_id", "yes")
put("include_all_episodes", "original")
}
}
val query = when (request) {
is SimklAllItemsRequest.Bootstrap -> mapOf(
"extended" to "full",
"episode_watched_at" to "yes",
"include_all_episodes" to "yes",
)
is SimklAllItemsRequest.Changes -> mapOf(
"date_from" to request.dateFrom,
"extended" to "full_anime_seasons",
"episode_watched_at" to "yes",
"episode_tvdb_id" to "yes",
"include_all_episodes" to "yes",
)
SimklAllItemsRequest.CurrentIds -> mapOf(
"extended" to "simkl_ids_only",
)
}
return client.execute(
SimklApiRequest(

View file

@ -50,9 +50,14 @@ class SimklSyncEngineTest {
assertEquals(1, result.playback.size)
assertEquals(500L, result.lastSyncedAtEpochMs)
assertTrue(remote.isExhausted)
assertTrue(remote.allItemsRequests.all { request ->
request.dateFrom == null && !request.includeEpisodeDetails && !request.idsOnly
})
assertEquals(
listOf<SimklAllItemsRequest>(
SimklAllItemsRequest.Bootstrap(SimklMediaType.SHOWS),
SimklAllItemsRequest.Bootstrap(SimklMediaType.MOVIES),
SimklAllItemsRequest.Bootstrap(SimklMediaType.ANIME),
),
remote.allItemsRequests,
)
}
@Test
@ -112,9 +117,13 @@ class SimklSyncEngineTest {
)
assertEquals("3", result.playback.single().media?.ids?.idValue("simkl"))
assertEquals("v2", result.watermark)
assertTrue(remote.allItemsRequests[0].includeEpisodeDetails)
assertEquals("v1", remote.allItemsRequests[0].dateFrom)
assertTrue(remote.allItemsRequests[1].idsOnly)
assertEquals(
listOf<SimklAllItemsRequest>(
SimklAllItemsRequest.Changes("v1"),
SimklAllItemsRequest.CurrentIds,
),
remote.allItemsRequests,
)
assertTrue(remote.isExhausted)
}
@ -139,7 +148,7 @@ class SimklSyncEngineTest {
}
@Test
fun `remote keeps initial pull minimal and adds rich flags only to dated delta`() = runBlocking {
fun `remote applies documented bootstrap delta and reconciliation parameters`() = runBlocking {
var now = 0L
val urls = mutableListOf<String>()
val engine = SimklHttpEngine { _, url, _, _ ->
@ -156,21 +165,20 @@ class SimklSyncEngineTest {
)
val remote = SimklApiSyncRemote(client)
remote.fetchAllItems(SimklAllItemsRequest(type = SimklMediaType.SHOWS))
remote.fetchAllItems(
SimklAllItemsRequest(
dateFrom = "2026-05-08T14:23:11Z",
includeEpisodeDetails = true,
),
)
remote.fetchAllItems(SimklAllItemsRequest(idsOnly = true))
remote.fetchAllItems(SimklAllItemsRequest.Bootstrap(SimklMediaType.SHOWS))
remote.fetchAllItems(SimklAllItemsRequest.Changes("2026-05-08T14:23:11Z"))
remote.fetchAllItems(SimklAllItemsRequest.CurrentIds)
assertFalse("date_from=" in urls[0])
assertFalse("extended=" in urls[0])
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("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])
assertTrue("include_all_episodes=original" in urls[1])
assertTrue("episode_tvdb_id=yes" in urls[1])
assertTrue("include_all_episodes=yes" in urls[1])
assertTrue("extended=simkl_ids_only" in urls[2])
}
@ -190,7 +198,7 @@ class SimklSyncEngineTest {
)
val response = SimklApiSyncRemote(client).fetchAllItems(
SimklAllItemsRequest(type = SimklMediaType.ANIME),
SimklAllItemsRequest.Bootstrap(SimklMediaType.ANIME),
)
assertTrue(response.entriesFor(SimklMediaType.ANIME).isEmpty())