From c2fc373234e704349b4643e005a5f06ea251e5c7 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:27:10 +0530 Subject: [PATCH] fix(simkl): reconcile mutations locally --- .../simkl/SimklApplicationAdapters.kt | 6 - .../app/features/simkl/SimklLibraryAdapter.kt | 1 - .../features/simkl/SimklMutationReceipt.kt | 278 +++++++++++++++++ .../simkl/SimklMutationReconciliation.kt | 150 ++++++++++ .../features/simkl/SimklMutationRepository.kt | 104 ++----- .../simkl/SimklScrobbleReconciliation.kt | 2 +- .../app/features/simkl/SimklScrobbleResult.kt | 4 +- .../app/features/simkl/SimklSyncRepository.kt | 22 ++ .../simkl/SimklMutationReconciliationTest.kt | 279 ++++++++++++++++++ 9 files changed, 750 insertions(+), 96 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationReceipt.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationReconciliation.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationReconciliationTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt index 5610f69c..cb9cc14c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt @@ -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) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt index 391936b1..df3b5e17 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt @@ -130,7 +130,6 @@ object SimklLibraryRepository { ) } } - refresh(TrackingRefreshIntent.INVALIDATED) return resolution } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationReceipt.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationReceipt.kt new file mode 100644 index 00000000..6d7d5792 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationReceipt.kt @@ -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, + ) : SimklCommittedMutation + + data class AddToHistory( + val items: List, + ) : SimklCommittedMutation + + data class RemoveFromHistory( + val items: List, + 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, + 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, + 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, + 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 = + 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 = + (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 { + 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 +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationReconciliation.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationReconciliation.kt new file mode 100644 index 00000000..d868094b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationReconciliation.kt @@ -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, + 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, + 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, +): 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.indexOfMatchingMedia(target: SimklMedia): Int = + indexOfFirst { entry -> entry.media?.matchesTarget(target) == true } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt index 3f9a52be..94d4b83f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt @@ -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): 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): 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 { - 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) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklScrobbleReconciliation.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklScrobbleReconciliation.kt index 10b235d5..fd7a3d99 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklScrobbleReconciliation.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklScrobbleReconciliation.kt @@ -235,7 +235,7 @@ private fun List.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 } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklScrobbleResult.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklScrobbleResult.kt index 57bb495f..d18c515a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklScrobbleResult.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklScrobbleResult.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt index e3ba8f30..9ebe2ba5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt @@ -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 diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationReconciliationTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationReconciliationTest.kt new file mode 100644 index 00000000..4fa0dd0e --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationReconciliationTest.kt @@ -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"), + ), + ) +}