mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-02 01:28:25 +00:00
fix(simkl): reconcile mutations locally
This commit is contained in:
parent
247afcbf00
commit
c2fc373234
9 changed files with 750 additions and 96 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -130,7 +130,6 @@ object SimklLibraryRepository {
|
|||
)
|
||||
}
|
||||
}
|
||||
refresh(TrackingRefreshIntent.INVALIDATED)
|
||||
return resolution
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
),
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue