fix(simkl): reconcile mutations locally

This commit is contained in:
tapframe 2026-07-30 14:27:10 +05:30
parent 247afcbf00
commit c2fc373234
9 changed files with 750 additions and 96 deletions

View file

@ -191,12 +191,6 @@ object SimklProgressRepository {
}
}
SimklSyncRepository.commitPlaybackRemoval(removed)
if (removed.isNotEmpty()) {
SimklSyncRepository.refreshAsync(
intent = TrackingRefreshIntent.INVALIDATED,
origin = SimklRefreshOrigin.PLAYBACK_REMOVAL,
)
}
}
private fun publish(syncState: SimklSyncUiState) {

View file

@ -130,7 +130,6 @@ object SimklLibraryRepository {
)
}
}
refresh(TrackingRefreshIntent.INVALIDATED)
return resolution
}

View file

@ -0,0 +1,278 @@
package com.nuvio.app.features.simkl
import com.nuvio.app.features.tracking.TrackingHistoryItem
import com.nuvio.app.features.tracking.TrackingListStatus
import com.nuvio.app.features.tracking.TrackingMediaKind
import com.nuvio.app.features.tracking.TrackingMediaReference
import com.nuvio.app.features.tracking.TrackingMutationResolution
import com.nuvio.app.features.tracking.TrackingMutationResult
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
internal data class SimklMutationReceipt(
val result: TrackingMutationResult,
val mutation: SimklCommittedMutation,
val requiresReconciliation: Boolean = false,
)
internal sealed interface SimklCommittedMutation {
data class MoveToList(
val items: List<SimklResolvedListMutation>,
) : SimklCommittedMutation
data class AddToHistory(
val items: List<SimklResolvedHistoryMutation>,
) : SimklCommittedMutation
data class RemoveFromHistory(
val items: List<TrackingMediaReference>,
val deleted: SimklDeletedCounts,
) : SimklCommittedMutation
}
internal data class SimklResolvedListMutation(
val request: TrackingMediaReference,
val status: SimklListStatus,
val responseMedia: SimklMedia,
)
internal data class SimklResolvedHistoryMutation(
val request: TrackingHistoryItem,
val status: SimklListStatus?,
val mediaType: SimklMediaType?,
val animeType: String?,
)
internal data class SimklDeletedCounts(
val movies: Int = 0,
val shows: Int = 0,
val episodes: Int = 0,
)
internal fun SimklApiResponse.toListMutationReceipt(
candidates: List<TrackingMediaReference>,
json: Json,
): SimklMutationReceipt {
val payload = payload(json)
val result = payload.toTrackingMutationResult(candidates.size)
val unmatched = candidates.toMutableList()
val accepted = buildList {
payload.objectValue("added")?.forEach { (bucket, value) ->
val expectedMovie = bucket == "movies"
(value as? JsonArray).orEmpty().forEach { element ->
val response = runCatching { element.jsonObject }.getOrNull()
?: return@forEach
val status = response.stringValue("to")?.toSimklListStatus()
?: return@forEach
val responseMedia = response.toResponseMedia()
val index = unmatched.indexOfFirst { candidate ->
(candidate.kind == TrackingMediaKind.MOVIE) == expectedMovie &&
responseMedia.matchesTarget(candidate.toSimklMedia())
}
if (index >= 0) {
add(
SimklResolvedListMutation(
request = unmatched.removeAt(index),
status = status,
responseMedia = responseMedia,
),
)
}
}
}
}
return SimklMutationReceipt(
result = result,
mutation = SimklCommittedMutation.MoveToList(accepted),
requiresReconciliation = accepted.size + result.notFoundCount < candidates.size,
)
}
internal fun SimklApiResponse.toHistoryMutationReceipt(
candidates: List<TrackingHistoryItem>,
json: Json,
): SimklMutationReceipt {
val payload = payload(json)
val result = payload.toTrackingMutationResult(candidates.size)
val notFound = payload.notFoundTargets()
val statuses = payload.historyStatuses()
val accepted = candidates
.filterNot { candidate -> notFound.any { target -> target.matches(candidate.media) } }
.map { candidate ->
val status = statuses.firstOrNull { record -> record.target.matches(candidate.media) }
SimklResolvedHistoryMutation(
request = candidate,
status = status?.status,
mediaType = status?.mediaType,
animeType = status?.animeType,
)
}
return SimklMutationReceipt(
result = result,
mutation = SimklCommittedMutation.AddToHistory(accepted),
requiresReconciliation = accepted.any { resolved ->
resolved.status == null ||
resolved.request.media.kind != TrackingMediaKind.MOVIE &&
resolved.request.media.episode == null
},
)
}
internal fun SimklApiResponse.toHistoryRemovalReceipt(
candidates: List<TrackingMediaReference>,
json: Json,
): SimklMutationReceipt {
val payload = payload(json)
val result = payload.toTrackingMutationResult(candidates.size)
val notFound = payload.notFoundTargets()
val accepted = candidates.filterNot { candidate ->
notFound.any { target -> target.matches(candidate) }
}
val deleted = payload.objectValue("deleted")
return SimklMutationReceipt(
result = result,
mutation = SimklCommittedMutation.RemoveFromHistory(
items = accepted,
deleted = SimklDeletedCounts(
movies = deleted?.intValue("movies") ?: 0,
shows = deleted?.intValue("shows") ?: 0,
episodes = deleted?.intValue("episodes") ?: 0,
),
),
requiresReconciliation = accepted.any { candidate ->
candidate.kind != TrackingMediaKind.MOVIE && candidate.episode != null
},
)
}
private data class SimklResponseTarget(
val media: SimklMedia,
) {
fun matches(reference: TrackingMediaReference): Boolean =
media.matchesTarget(reference.toSimklMedia())
}
private data class SimklHistoryStatus(
val target: SimklResponseTarget,
val status: SimklListStatus?,
val mediaType: SimklMediaType?,
val animeType: String?,
)
private fun SimklApiResponse.payload(json: Json): JsonObject =
body
.takeIf(String::isNotBlank)
?.let { value -> runCatching { json.parseToJsonElement(value).jsonObject }.getOrNull() }
?: JsonObject(emptyMap())
private fun JsonObject.toTrackingMutationResult(attemptedCount: Int): TrackingMutationResult {
val notFoundCount = objectValue("not_found")
?.values
?.sumOf { value -> (value as? JsonArray)?.size ?: 0 }
?: 0
return TrackingMutationResult(
attemptedCount = attemptedCount,
notFoundCount = notFoundCount,
resolutions = objectValue("added")?.toMutationResolutions().orEmpty(),
)
}
private fun JsonObject.notFoundTargets(): List<SimklResponseTarget> =
objectValue("not_found")
?.values
?.flatMap { value ->
(value as? JsonArray).orEmpty().mapNotNull { element ->
runCatching { element.jsonObject }.getOrNull()
?.toResponseMedia()
?.let(::SimklResponseTarget)
}
}
.orEmpty()
private fun JsonObject.historyStatuses(): List<SimklHistoryStatus> =
(objectValue("added")?.get("statuses") as? JsonArray)
.orEmpty()
.mapNotNull { element ->
val item = runCatching { element.jsonObject }.getOrNull()
?: return@mapNotNull null
val request = item.objectValue("request") ?: return@mapNotNull null
val response = item.objectValue("response") ?: return@mapNotNull null
SimklHistoryStatus(
target = SimklResponseTarget(request.toResponseMedia()),
status = response.stringValue("status")?.toSimklListStatus(),
mediaType = response.stringValue("simkl_type")?.toSimklMediaType(),
animeType = response.stringValue("anime_type"),
)
}
private fun JsonObject.toMutationResolutions(): List<TrackingMutationResolution> {
val historyResolutions = (get("statuses") as? JsonArray)
.orEmpty()
.mapNotNull { element ->
val response = runCatching { element.jsonObject["response"]?.jsonObject }.getOrNull()
?: return@mapNotNull null
response.toMutationResolution(statusKey = "status")
}
if (historyResolutions.isNotEmpty()) return historyResolutions
return entries.flatMap { (bucket, value) ->
(value as? JsonArray).orEmpty().mapNotNull { element ->
runCatching { element.jsonObject }.getOrNull()
?.toMutationResolution(statusKey = "to", fallbackKind = bucket.toTrackingMediaKind())
}
}
}
private fun JsonObject.toMutationResolution(
statusKey: String,
fallbackKind: TrackingMediaKind? = null,
): TrackingMutationResolution? {
val status = TrackingListStatus.fromWireValue(stringValue(statusKey))
val mediaKind = stringValue("simkl_type")?.toTrackingMediaKind()
?: stringValue("type")?.toTrackingMediaKind()
?: fallbackKind
val providerSubtype = stringValue("anime_type")
if (status == null && mediaKind == null && providerSubtype == null) return null
return TrackingMutationResolution(
listStatus = status,
mediaKind = mediaKind,
providerSubtype = providerSubtype,
)
}
private fun JsonObject.toResponseMedia(): SimklMedia = SimklMedia(
title = stringValue("title"),
year = intValue("year"),
ids = objectValue("ids")?.toMap().orEmpty(),
)
private fun JsonObject.objectValue(key: String): JsonObject? =
runCatching { get(key)?.jsonObject }.getOrNull()
private fun JsonObject.stringValue(key: String): String? =
runCatching { get(key)?.jsonPrimitive?.content }
.getOrNull()
?.trim()
?.takeIf(String::isNotEmpty)
private fun JsonObject.intValue(key: String): Int? = stringValue(key)?.toIntOrNull()
private fun String.toSimklListStatus(): SimklListStatus? =
SimklListStatus.entries.firstOrNull { status -> status.apiValue == lowercase() }
private fun String.toSimklMediaType(): SimklMediaType? = when (lowercase()) {
"movie", "movies" -> SimklMediaType.MOVIES
"anime" -> SimklMediaType.ANIME
"tv", "show", "shows" -> SimklMediaType.SHOWS
else -> null
}
private fun String.toTrackingMediaKind(): TrackingMediaKind? = when (lowercase()) {
"movie", "movies" -> TrackingMediaKind.MOVIE
"anime" -> TrackingMediaKind.ANIME
"tv", "show", "shows" -> TrackingMediaKind.SHOW
else -> null
}

View file

@ -0,0 +1,150 @@
package com.nuvio.app.features.simkl
import com.nuvio.app.features.tracking.TrackingMediaKind
import com.nuvio.app.features.tracking.TrackingMediaReference
internal fun SimklSyncSnapshot.applyMutationReceipt(
receipt: SimklMutationReceipt,
committedAtEpochMs: Long,
): SimklSyncSnapshot {
val committedAt = committedAtEpochMs.epochMsToUtcIso() ?: return this
return when (val mutation = receipt.mutation) {
is SimklCommittedMutation.MoveToList -> withListMutations(mutation.items, committedAt)
is SimklCommittedMutation.AddToHistory -> withHistoryMutations(
mutation.items,
committedAtEpochMs,
)
is SimklCommittedMutation.RemoveFromHistory -> withoutHistory(mutation.items)
}.reconcileWatchedPlayback()
}
private fun SimklSyncSnapshot.withListMutations(
mutations: List<SimklResolvedListMutation>,
committedAt: String,
): SimklSyncSnapshot {
if (mutations.isEmpty()) return this
val updated = entries.toMutableList()
mutations.forEach { mutation ->
val requestMedia = mutation.request.toSimklMedia()
val media = mutation.responseMedia.mergeMissing(requestMedia)
val index = updated.indexOfMatchingMedia(media)
val existing = updated.getOrNull(index)
val mediaType = existing?.mediaType ?: mutation.request.kind.toSimklMediaType()
val mergedMedia = media.mergeMissing(existing?.media)
val entry = (existing ?: SimklLibraryEntry()).copy(
mediaType = mediaType,
addedToWatchlistAt = existing?.addedToWatchlistAt ?: committedAt,
status = mutation.status,
show = mergedMedia.takeIf { mediaType != SimklMediaType.MOVIES },
movie = mergedMedia.takeIf { mediaType == SimklMediaType.MOVIES },
)
if (index >= 0) {
updated[index] = entry
} else {
updated += entry
}
}
return copy(entries = updated)
}
private fun SimklSyncSnapshot.withHistoryMutations(
mutations: List<SimklResolvedHistoryMutation>,
committedAtEpochMs: Long,
): SimklSyncSnapshot {
var updated = this
mutations.forEach { mutation ->
val request = mutation.request
val mediaType = mutation.mediaType ?: request.media.kind.toSimklMediaType()
updated = updated.applyScrobbleResult(
result = SimklScrobbleResult(
outcome = SimklScrobbleOutcome.SCROBBLE,
playbackId = null,
progress = 100.0,
mediaType = mediaType,
media = request.media.toSimklMedia(),
episode = request.media.episode?.let { episode ->
SimklPlaybackEpisode(
season = episode.season,
number = episode.number,
title = episode.title,
)
},
watchedAt = request.watchedAtEpochMs?.epochMsToUtcIso(),
),
committedAtEpochMs = committedAtEpochMs,
)
updated = updated.withResolvedHistoryStatus(mutation)
}
return updated
}
private fun SimklSyncSnapshot.withResolvedHistoryStatus(
mutation: SimklResolvedHistoryMutation,
): SimklSyncSnapshot {
val target = mutation.request.media.toSimklMedia()
val index = entries.indexOfMatchingMedia(target)
val existing = entries.getOrNull(index) ?: return this
val mediaType = mutation.mediaType ?: existing.mediaType
val media = existing.media ?: target
val updated = existing.copy(
mediaType = mediaType,
status = mutation.status ?: existing.status,
animeType = mutation.animeType ?: existing.animeType,
show = media.takeIf { mediaType != SimklMediaType.MOVIES },
movie = media.takeIf { mediaType == SimklMediaType.MOVIES },
)
return copy(entries = entries.toMutableList().apply { this[index] = updated })
}
private fun SimklSyncSnapshot.withoutHistory(
mutations: List<TrackingMediaReference>,
): SimklSyncSnapshot {
if (mutations.isEmpty()) return this
var updated = entries
mutations.forEach { mutation ->
val target = mutation.toSimklMedia()
if (mutation.kind == TrackingMediaKind.MOVIE || mutation.episode == null) {
updated = updated.filterNot { entry -> entry.media?.matchesTarget(target) == true }
} else {
updated = updated.map { entry ->
if (entry.media?.matchesTarget(target) == true) {
entry.withoutWatchedEpisode(
season = mutation.episode.season,
number = mutation.episode.number,
)
} else {
entry
}
}
}
}
return copy(entries = updated)
}
private fun SimklLibraryEntry.withoutWatchedEpisode(
season: Int?,
number: Int,
): SimklLibraryEntry {
val targetSeason = season ?: 1
val updatedSeasons = seasons.map { candidateSeason ->
candidateSeason.copy(
episodes = candidateSeason.episodes.map { episode ->
val matchesCanonical = candidateSeason.number == targetSeason && episode.number == number
val matchesTvdb = episode.tvdb?.season == targetSeason &&
episode.tvdb.episode == number
if (matchesCanonical || matchesTvdb) episode.copy(watchedAt = null) else episode
},
)
}
val watchedEpisodes = updatedSeasons
.flatMap(SimklSeason::episodes)
.filter { episode -> episode.watchedAt != null }
return copy(
lastWatchedAt = watchedEpisodes.maxOfOrNull { episode -> requireNotNull(episode.watchedAt) },
watchedEpisodesCount = watchedEpisodes.size,
seasons = updatedSeasons,
)
}
private fun List<SimklLibraryEntry>.indexOfMatchingMedia(target: SimklMedia): Int =
indexOfFirst { entry -> entry.media?.matchesTarget(target) == true }

View file

@ -10,7 +10,6 @@ import com.nuvio.app.features.tracking.TrackingListWriter
import com.nuvio.app.features.tracking.TrackingMediaKind
import com.nuvio.app.features.tracking.TrackingMediaReference
import com.nuvio.app.features.tracking.TrackingMutationResult
import com.nuvio.app.features.tracking.TrackingMutationResolution
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingProviderRegistry
import com.nuvio.app.features.tracking.TrackingRefreshIntent
@ -21,17 +20,14 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import kotlin.math.round
internal class SimklMutationService(
private val client: SimklApiClient,
private val onMutationCommitted: () -> Unit = {},
private val onMutationCommitted: suspend (SimklMutationReceipt) -> Unit = {},
) {
private val json = Json {
ignoreUnknownKeys = true
@ -53,8 +49,9 @@ internal class SimklMutationService(
retryPolicy = SimklRetryPolicy.SYNC_WRITE,
),
)
onMutationCommitted()
return response.toMutationResult(candidates.size, json)
val receipt = response.toListMutationReceipt(candidates, json)
onMutationCommitted(receipt)
return receipt.result
}
suspend fun removeFromList(items: Collection<TrackingMediaReference>): TrackingMutationResult =
@ -75,8 +72,9 @@ internal class SimklMutationService(
retryPolicy = SimklRetryPolicy.SYNC_WRITE,
),
)
onMutationCommitted()
return response.toMutationResult(candidates.size, json)
val receipt = response.toHistoryMutationReceipt(candidates, json)
onMutationCommitted(receipt)
return receipt.result
}
suspend fun removeFromHistory(items: Collection<TrackingMediaReference>): TrackingMutationResult {
@ -90,8 +88,9 @@ internal class SimklMutationService(
retryPolicy = SimklRetryPolicy.SYNC_WRITE,
),
)
onMutationCommitted()
return response.toMutationResult(candidates.size, json)
val receipt = response.toHistoryRemovalReceipt(candidates, json)
onMutationCommitted(receipt)
return receipt.result
}
suspend fun scrobble(
@ -128,11 +127,14 @@ object SimklMutationRepository : TrackingListWriter, TrackingHistoryWriter, Trac
private val service by lazy {
SimklMutationService(
client = SimklApi.client,
onMutationCommitted = {
SimklSyncRepository.refreshAsync(
intent = TrackingRefreshIntent.INVALIDATED,
origin = SimklRefreshOrigin.MUTATION,
)
onMutationCommitted = { receipt ->
SimklSyncRepository.commitMutation(receipt)
if (receipt.requiresReconciliation) {
SimklSyncRepository.refreshAsync(
intent = TrackingRefreshIntent.INVALIDATED,
origin = SimklRefreshOrigin.MUTATION,
)
}
},
)
}
@ -393,76 +395,6 @@ internal fun TrackingExternalIds.toSimklJsonObjectOrNull(): JsonObject? {
return value.takeIf { it.isNotEmpty() }
}
private fun SimklApiResponse.toMutationResult(attemptedCount: Int, json: Json): TrackingMutationResult {
val payload = body
.takeIf(String::isNotBlank)
?.let { value -> runCatching { json.parseToJsonElement(value).jsonObject }.getOrNull() }
val notFound = payload
?.get("not_found")
?.let { value -> runCatching { value.jsonObject }.getOrNull() }
val notFoundCount = notFound
?.values
?.sumOf { value -> (value as? JsonArray)?.size ?: 0 }
?: 0
val added = payload
?.get("added")
?.let { value -> runCatching { value.jsonObject }.getOrNull() }
val resolutions = added?.toMutationResolutions().orEmpty()
return TrackingMutationResult(
attemptedCount = attemptedCount,
notFoundCount = notFoundCount,
resolutions = resolutions,
)
}
private fun JsonObject.toMutationResolutions(): List<TrackingMutationResolution> {
val historyResolutions = (get("statuses") as? JsonArray)
.orEmpty()
.mapNotNull { element ->
val response = runCatching { element.jsonObject["response"]?.jsonObject }.getOrNull()
?: return@mapNotNull null
response.toMutationResolution(statusKey = "status")
}
if (historyResolutions.isNotEmpty()) return historyResolutions
return entries.flatMap { (bucket, value) ->
(value as? JsonArray).orEmpty().mapNotNull { element ->
runCatching { element.jsonObject }.getOrNull()
?.toMutationResolution(statusKey = "to", fallbackKind = bucket.toTrackingMediaKind())
}
}
}
private fun JsonObject.toMutationResolution(
statusKey: String,
fallbackKind: TrackingMediaKind? = null,
): TrackingMutationResolution? {
val status = TrackingListStatus.fromWireValue(stringValue(statusKey))
val mediaKind = stringValue("simkl_type")?.toTrackingMediaKind()
?: stringValue("type")?.toTrackingMediaKind()
?: fallbackKind
val providerSubtype = stringValue("anime_type")
if (status == null && mediaKind == null && providerSubtype == null) return null
return TrackingMutationResolution(
listStatus = status,
mediaKind = mediaKind,
providerSubtype = providerSubtype,
)
}
private fun JsonObject.stringValue(key: String): String? =
runCatching { get(key)?.jsonPrimitive?.content }
.getOrNull()
?.trim()
?.takeIf(String::isNotEmpty)
private fun String.toTrackingMediaKind(): TrackingMediaKind? = when (lowercase()) {
"movie", "movies" -> TrackingMediaKind.MOVIE
"anime" -> TrackingMediaKind.ANIME
"tv", "show", "shows" -> TrackingMediaKind.SHOW
else -> null
}
private fun Double.clampAndRoundProgress(): Double = round(coerceIn(0.0, 100.0) * 100.0) / 100.0
private fun String?.nonBlankOrNull(): String? = this?.trim()?.takeIf(String::isNotEmpty)

View file

@ -235,7 +235,7 @@ private fun List<SimklLibraryEntry>.indexOfMatchingEntry(
}
}
private fun SimklMedia.matchesTarget(target: SimklMedia): Boolean {
internal fun SimklMedia.matchesTarget(target: SimklMedia): Boolean {
val candidateIds = toTrackingExternalIds()
val targetIds = target.toTrackingExternalIds()
candidateIds.comparableMatch(targetIds)?.let { return it }

View file

@ -112,13 +112,13 @@ private fun JsonObject.episode(
)
}
private fun TrackingMediaReference.toSimklMedia(): SimklMedia = SimklMedia(
internal fun TrackingMediaReference.toSimklMedia(): SimklMedia = SimklMedia(
title = title?.takeIf(String::isNotBlank),
year = year,
ids = ids.toSimklJsonObjectOrNull()?.toMap().orEmpty(),
)
private fun TrackingMediaKind.toSimklMediaType(): SimklMediaType = when (this) {
internal fun TrackingMediaKind.toSimklMediaType(): SimklMediaType = when (this) {
TrackingMediaKind.MOVIE -> SimklMediaType.MOVIES
TrackingMediaKind.SHOW -> SimklMediaType.SHOWS
TrackingMediaKind.ANIME -> SimklMediaType.ANIME

View file

@ -215,6 +215,28 @@ object SimklSyncRepository : TrackingProfileStore {
}
}
internal suspend fun commitMutation(receipt: SimklMutationReceipt) {
ensureLoaded()
val generation = profileGeneration
val profileId = ProfileRepository.activeProfileId
snapshotMutex.withLock {
if (generation != profileGeneration || profileId != ProfileRepository.activeProfileId) {
return@withLock
}
val current = _state.value
val snapshot = current.snapshot.applyMutationReceipt(
receipt = receipt,
committedAtEpochMs = SimklPlatformClock.nowEpochMs(),
)
if (snapshot == current.snapshot) return@withLock
SimklSyncStorage.savePayload(json.encodeToString(snapshot))
if (generation == profileGeneration && profileId == ProfileRepository.activeProfileId) {
_state.value = current.copy(snapshot = snapshot)
SimklWatchDiagnostics.logSnapshot(stage = "mutation-commit", snapshot = snapshot)
}
}
}
override fun onProfileChanged() {
profileGeneration += 1L
hasLoaded = false

View file

@ -0,0 +1,279 @@
package com.nuvio.app.features.simkl
import com.nuvio.app.features.tracking.TrackingEpisode
import com.nuvio.app.features.tracking.TrackingExternalIds
import com.nuvio.app.features.tracking.TrackingHistoryItem
import com.nuvio.app.features.tracking.TrackingListStatus
import com.nuvio.app.features.tracking.TrackingMediaKind
import com.nuvio.app.features.tracking.TrackingMediaReference
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class SimklMutationReconciliationTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun `list response commits the server rewritten status without advancing watermark`() {
val request = movie()
val receipt = response(
"""
{
"added": {
"movies": [
{
"to": "completed",
"ids": {"simkl": 472214, "imdb": "tt1375666"},
"type": "movie"
}
],
"shows": []
},
"not_found": {"movies": [], "shows": []}
}
""",
).toListMutationReceipt(listOf(request), json)
val updated = SimklSyncSnapshot(
isInitialized = true,
watermark = "v1",
).applyMutationReceipt(receipt, 1_700_000_000_000L)
assertEquals("v1", updated.watermark)
assertEquals(SimklListStatus.COMPLETED, updated.entries.single().status)
assertEquals("472214", updated.entries.single().media?.ids?.simklIdValue())
assertFalse(receipt.requiresReconciliation)
assertEquals(listOf(TrackingListStatus.COMPLETED), receipt.result.resolvedListStatuses)
}
@Test
fun `partial list response commits only items present in added`() {
val accepted = movie()
val missing = movie().copy(
title = "Missing",
ids = TrackingExternalIds(imdb = "tt0000000"),
)
val receipt = response(
"""
{
"added": {
"movies": [
{
"to": "plantowatch",
"ids": {"simkl": 472214, "imdb": "tt1375666"},
"type": "movie"
}
],
"shows": []
},
"not_found": {
"movies": [{"title": "Missing", "ids": {"imdb": "tt0000000"}}],
"shows": []
}
}
""",
).toListMutationReceipt(listOf(accepted, missing), json)
val updated = SimklSyncSnapshot().applyMutationReceipt(receipt, 1_700_000_000_000L)
assertEquals(1, updated.entries.size)
assertEquals(SimklListStatus.PLAN_TO_WATCH, updated.entries.single().status)
assertEquals(1, receipt.result.notFoundCount)
assertFalse(receipt.result.isComplete)
}
@Test
fun `history response records episode and resolved anime classification locally`() {
val request = TrackingHistoryItem(
media = anime(TrackingEpisode(number = 3)),
watchedAtEpochMs = 1_700_000_000_000L,
)
val receipt = response(
"""
{
"added": {
"movies": 0,
"shows": 1,
"episodes": 1,
"statuses": [
{
"request": {
"ids": {"simkl": 39687, "mal": 16498},
"type": "show"
},
"response": {
"status": "watching",
"simkl_type": "anime",
"anime_type": "tv"
}
}
]
},
"not_found": {"movies": [], "shows": [], "episodes": []}
}
""",
).toHistoryMutationReceipt(listOf(request), json)
val updated = SimklSyncSnapshot(
isInitialized = true,
watermark = "v1",
).applyMutationReceipt(receipt, 1_700_000_100_000L)
val entry = updated.entries.single()
assertEquals("v1", updated.watermark)
assertEquals(SimklMediaType.ANIME, entry.mediaType)
assertEquals(SimklListStatus.WATCHING, entry.status)
assertEquals("tv", entry.animeType)
assertEquals("2023-11-14T22:13:20Z", entry.seasons.single().episodes.single().watchedAt)
assertFalse(receipt.requiresReconciliation)
}
@Test
fun `whole show history response keeps reconciliation because server expands episodes`() {
val request = TrackingHistoryItem(media = show())
val receipt = response(
"""
{
"added": {
"movies": 0,
"shows": 1,
"episodes": 6,
"statuses": [
{
"request": {"ids": {"simkl": 2090}, "type": "show"},
"response": {"status": "watching", "simkl_type": "tv"}
}
]
},
"not_found": {"movies": [], "shows": [], "episodes": []}
}
""",
).toHistoryMutationReceipt(listOf(request), json)
assertTrue(receipt.requiresReconciliation)
}
@Test
fun `episode history removal clears watched state and retains reconciliation`() {
val request = show(TrackingEpisode(season = 1, number = 2))
val receipt = response(
"""
{
"deleted": {"movies": 0, "shows": 0, "episodes": 1},
"not_found": {"movies": [], "shows": []}
}
""",
).toHistoryRemovalReceipt(listOf(request), json)
val media = showMedia()
val updated = SimklSyncSnapshot(
isInitialized = true,
watermark = "v1",
entries = listOf(
SimklLibraryEntry(
mediaType = SimklMediaType.SHOWS,
status = SimklListStatus.WATCHING,
lastWatchedAt = "2023-11-14T22:13:20Z",
watchedEpisodesCount = 1,
show = media,
seasons = listOf(
SimklSeason(
number = 1,
episodes = listOf(
SimklEpisode(number = 2, watchedAt = "2023-11-14T22:13:20Z"),
),
),
),
),
),
).applyMutationReceipt(receipt, 1_700_000_100_000L)
val entry = updated.entries.single()
assertEquals("v1", updated.watermark)
assertNull(entry.seasons.single().episodes.single().watchedAt)
assertEquals(0, entry.watchedEpisodesCount)
assertNull(entry.lastWatchedAt)
assertTrue(receipt.requiresReconciliation)
assertEquals(1, (receipt.mutation as SimklCommittedMutation.RemoveFromHistory).deleted.episodes)
}
@Test
fun `whole title removal deletes local entry without reconciliation`() {
val request = movie()
val receipt = response(
"""
{
"deleted": {"movies": 1, "shows": 0, "episodes": 0},
"not_found": {"movies": [], "shows": []}
}
""",
).toHistoryRemovalReceipt(listOf(request), json)
val snapshot = SimklSyncSnapshot(
isInitialized = true,
watermark = "v1",
entries = listOf(
SimklLibraryEntry(
mediaType = SimklMediaType.MOVIES,
status = SimklListStatus.COMPLETED,
movie = movieMedia(),
),
),
)
val updated = snapshot.applyMutationReceipt(receipt, 1_700_000_000_000L)
assertTrue(updated.entries.isEmpty())
assertEquals("v1", updated.watermark)
assertFalse(receipt.requiresReconciliation)
}
private fun response(body: String) = SimklApiResponse(
status = 201,
body = body,
headers = emptyMap(),
)
private fun movie() = TrackingMediaReference(
kind = TrackingMediaKind.MOVIE,
title = "Inception",
year = 2010,
ids = TrackingExternalIds(simkl = 472214, imdb = "tt1375666"),
)
private fun show(episode: TrackingEpisode? = null) = TrackingMediaReference(
kind = TrackingMediaKind.SHOW,
title = "The Walking Dead",
year = 2010,
ids = TrackingExternalIds(simkl = 2090, imdb = "tt1520211"),
episode = episode,
)
private fun anime(episode: TrackingEpisode? = null) = TrackingMediaReference(
kind = TrackingMediaKind.ANIME,
title = "Attack on Titan",
year = 2013,
ids = TrackingExternalIds(simkl = 39687, mal = 16498),
episode = episode,
)
private fun movieMedia() = SimklMedia(
title = "Inception",
year = 2010,
ids = mapOf(
"simkl" to JsonPrimitive(472214L),
"imdb" to JsonPrimitive("tt1375666"),
),
)
private fun showMedia() = SimklMedia(
title = "The Walking Dead",
year = 2010,
ids = mapOf(
"simkl" to JsonPrimitive(2090L),
"imdb" to JsonPrimitive("tt1520211"),
),
)
}