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 45a8b4f50..013bcace5 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,6 +10,7 @@ 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 @@ -378,25 +379,65 @@ private fun SimklApiResponse.toMutationResult(attemptedCount: Int, json: Json): ?.values ?.sumOf { value -> (value as? JsonArray)?.size ?: 0 } ?: 0 - val resolvedListStatuses = payload + val added = payload ?.get("added") ?.let { value -> runCatching { value.jsonObject }.getOrNull() } - ?.values - .orEmpty() - .flatMap { value -> (value as? JsonArray).orEmpty() } - .mapNotNull { value -> - val wireValue = runCatching { - value.jsonObject["to"]?.jsonPrimitive?.content - }.getOrNull() - TrackingListStatus.fromWireValue(wireValue) - } + val resolutions = added?.toMutationResolutions().orEmpty() return TrackingMutationResult( attemptedCount = attemptedCount, notFoundCount = notFoundCount, - resolvedListStatuses = resolvedListStatuses, + 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/tracking/TrackingWrites.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingWrites.kt index 51a3298b1..b74afa369 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingWrites.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingWrites.kt @@ -32,12 +32,21 @@ data class TrackingScrobbleEvent( data class TrackingMutationResult( val attemptedCount: Int, val notFoundCount: Int = 0, - val resolvedListStatuses: List = emptyList(), + val resolutions: List = emptyList(), ) { + val resolvedListStatuses: List + get() = resolutions.mapNotNull(TrackingMutationResolution::listStatus) + val isComplete: Boolean get() = notFoundCount == 0 } +data class TrackingMutationResolution( + val listStatus: TrackingListStatus? = null, + val mediaKind: TrackingMediaKind? = null, + val providerSubtype: String? = null, +) + interface TrackingListWriter { val providerId: TrackingProviderId diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt index 4ebad69c3..81228c955 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt @@ -141,6 +141,58 @@ class SimklMutationRepositoryTest { assertEquals(2, committed) } + @Test + fun `history response exposes resolved status catalog and anime subtype`() = runBlocking { + val engine = RecordingEngine( + response( + status = 201, + body = """ + { + "added": { + "movies": 0, + "shows": 1, + "episodes": 1, + "statuses": [ + { + "request": {"title":"Attack on Titan","type":"show"}, + "response": { + "status":"watching", + "simkl_type":"anime", + "anime_type":"tv" + } + } + ] + }, + "not_found": {"movies":[],"shows":[],"episodes":[]} + } + """.trimIndent(), + ), + ) + val service = SimklMutationService( + client = SimklApiClient( + engine = engine, + accessToken = { "token" }, + onUnauthorized = {}, + nowEpochMs = { 0L }, + sleep = {}, + retryJitterMs = { 0L }, + ), + ) + + val result = service.addToHistory( + listOf( + TrackingHistoryItem( + media = anime(TrackingEpisode(number = 1)), + watchedAtEpochMs = 1_700_000_000_000L, + ), + ), + ) + + assertEquals(listOf(TrackingListStatus.WATCHING), result.resolvedListStatuses) + assertEquals(TrackingMediaKind.ANIME, result.resolutions.single().mediaKind) + assertEquals("tv", result.resolutions.single().providerSubtype) + } + @Test fun `service leaves failed scrobble retry to the next player event`() = runBlocking { val engine = RecordingEngine(response(status = 503), response(status = 200))