feat(simkl): add atomic sync store

This commit is contained in:
tapframe 2026-07-22 02:22:22 +05:30
parent f7c17e656e
commit 615098e976
14 changed files with 880 additions and 8 deletions

View file

@ -49,6 +49,7 @@ import com.nuvio.app.features.trakt.TraktCommentsStorage
import com.nuvio.app.features.trakt.TraktLibraryStorage
import com.nuvio.app.features.trakt.TraktSettingsStorage
import com.nuvio.app.features.simkl.SimklAuthStorage
import com.nuvio.app.features.simkl.SimklSyncStorage
import com.nuvio.app.features.tmdb.TmdbSettingsStorage
import com.nuvio.app.features.updater.AndroidAppUpdaterPlatform
import com.nuvio.app.core.ui.CardDepthStyleStorage
@ -106,6 +107,7 @@ class MainActivity : AppCompatActivity() {
TraktLibraryStorage.initialize(applicationContext)
TraktSettingsStorage.initialize(applicationContext)
SimklAuthStorage.initialize(applicationContext)
SimklSyncStorage.initialize(applicationContext)
LibraryDisplaySettingsStorage.initialize(applicationContext)
ContinueWatchingPreferencesStorage.initialize(applicationContext)
ResumePromptStorage.initialize(applicationContext)

View file

@ -20,6 +20,7 @@ internal actual object PlatformLocalAccountDataCleaner {
"nuvio_auth",
"nuvio_trakt_auth",
"nuvio_simkl_auth",
"nuvio_simkl_sync",
"nuvio_trakt_library",
"nuvio_trakt_settings",
"nuvio_watched",

View file

@ -0,0 +1,29 @@
package com.nuvio.app.features.simkl
import android.content.Context
import android.content.SharedPreferences
import com.nuvio.app.core.storage.ProfileScopedKey
internal actual object SimklSyncStorage {
private const val PREFERENCES_NAME = "nuvio_simkl_sync"
private const val PAYLOAD_KEY = "simkl_sync_snapshot"
private var preferences: SharedPreferences? = null
fun initialize(context: Context) {
preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
}
actual fun loadPayload(): String? =
preferences?.getString(ProfileScopedKey.of(PAYLOAD_KEY), null)
actual fun savePayload(payload: String) {
preferences?.edit()?.putString(ProfileScopedKey.of(PAYLOAD_KEY), payload)?.apply()
}
actual fun removeProfile(profileId: Int) {
preferences?.edit()
?.remove(ProfileScopedKey.of(PAYLOAD_KEY, profileId))
?.apply()
}
}

View file

@ -1,9 +1,11 @@
package com.nuvio.app.core.tracking
import com.nuvio.app.features.simkl.SimklAuthRepository
import com.nuvio.app.features.simkl.SimklSyncRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
fun ensureTrackingProvidersRegistered() {
TraktAuthRepository.descriptor
SimklAuthRepository.descriptor
SimklSyncRepository.state
}

View file

@ -166,6 +166,7 @@ object SimklAuthRepository : TrackingAuthProvider {
clearPendingAuthorization()
storedState = SimklStoredAuthState()
persistMetadata()
SimklSyncRepository.clearLocalState()
publish(error = null)
}
@ -261,6 +262,7 @@ object SimklAuthRepository : TrackingAuthProvider {
persistMetadata()
publish(isLoading = false, error = null)
fetchAndStoreUserSettings()
SimklSyncRepository.refreshAsync()
}
private suspend fun fetchAndStoreUserSettings(): String? {
@ -327,6 +329,7 @@ object SimklAuthRepository : TrackingAuthProvider {
clearPendingAuthorization()
storedState = SimklStoredAuthState()
persistMetadata()
SimklSyncRepository.clearLocalState()
publish(isLoading = false, error = error)
}

View file

@ -0,0 +1,117 @@
package com.nuvio.app.features.simkl
internal class SimklSyncEngine(
private val remote: SimklSyncRemote,
private val nowEpochMs: () -> Long,
) {
suspend fun synchronize(current: SimklSyncSnapshot): SimklSyncSnapshot {
if (!current.isInitialized) return initialSync()
val activities = remote.fetchActivities()
if (activities.all == current.watermark) {
return current.copy(
activities = activities,
lastCheckedAtEpochMs = nowEpochMs(),
)
}
if (current.watermark == null) return initialSync()
val delta = remote.fetchAllItems(
SimklAllItemsRequest(
dateFrom = current.watermark,
includeEpisodeDetails = true,
),
)
var entries = mergeDelta(current.entries, delta)
if (hasRemovalActivityChanged(current.activities, activities)) {
val authoritativeIds = remote.fetchAllItems(
SimklAllItemsRequest(idsOnly = true),
)
entries = reconcileRemovedEntries(entries, authoritativeIds)
}
val playback = if (hasPlaybackActivityChanged(current.activities, activities)) {
remote.fetchPlayback()
} else {
current.playback
}
val now = nowEpochMs()
return current.copy(
watermark = activities.all,
activities = activities,
entries = entries,
playback = playback,
lastSyncedAtEpochMs = now,
lastCheckedAtEpochMs = now,
)
}
private suspend fun initialSync(): SimklSyncSnapshot {
val entries = buildList {
SimklMediaType.entries.forEach { type ->
addAll(
remote.fetchAllItems(SimklAllItemsRequest(type = type))
.entriesFor(type),
)
}
}
val playback = remote.fetchPlayback()
val activities = remote.fetchActivities()
val now = nowEpochMs()
return SimklSyncSnapshot(
isInitialized = true,
watermark = activities.all,
activities = activities,
entries = entries.distinctBy(SimklLibraryEntry::stableKey),
playback = playback,
lastSyncedAtEpochMs = now,
lastCheckedAtEpochMs = now,
)
}
}
internal fun mergeDelta(
current: List<SimklLibraryEntry>,
delta: SimklAllItemsResponse,
): List<SimklLibraryEntry> {
val merged = current.mapNotNull { entry -> entry.stableKey()?.let { key -> key to entry } }.toMap().toMutableMap()
delta.presentTypes().forEach { type ->
delta.entriesFor(type).forEach { entry ->
entry.stableKey()?.let { key -> merged[key] = entry }
}
}
return merged.values.sortedWith(simklEntryComparator)
}
internal fun reconcileRemovedEntries(
current: List<SimklLibraryEntry>,
authoritative: SimklAllItemsResponse,
): List<SimklLibraryEntry> {
val allowedKeys = SimklMediaType.entries.flatMapTo(mutableSetOf()) { type ->
authoritative.entriesFor(type).mapNotNull(SimklLibraryEntry::stableKey)
}
return current.filter { entry -> entry.stableKey() in allowedKeys }
.sortedWith(simklEntryComparator)
}
private fun hasRemovalActivityChanged(
previous: SimklActivities?,
current: SimklActivities,
): Boolean = SimklMediaType.entries.any { type ->
previous?.domain(type)?.removedFromList != current.domain(type).removedFromList
}
private fun hasPlaybackActivityChanged(
previous: SimklActivities?,
current: SimklActivities,
): Boolean = SimklMediaType.entries.any { type ->
previous?.domain(type)?.playback != current.domain(type).playback
}
private val simklEntryComparator = compareBy<SimklLibraryEntry>(
{ entry -> entry.mediaType.ordinal },
{ entry -> entry.media?.title.orEmpty().lowercase() },
{ entry -> entry.stableKey().orEmpty() },
)

View file

@ -0,0 +1,193 @@
package com.nuvio.app.features.simkl
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.jsonPrimitive
@Serializable
enum class SimklMediaType(val apiValue: String) {
@SerialName("shows") SHOWS("shows"),
@SerialName("movies") MOVIES("movies"),
@SerialName("anime") ANIME("anime"),
}
@Serializable
enum class SimklListStatus(val apiValue: String) {
@SerialName("watching") WATCHING("watching"),
@SerialName("plantowatch") PLAN_TO_WATCH("plantowatch"),
@SerialName("hold") ON_HOLD("hold"),
@SerialName("completed") COMPLETED("completed"),
@SerialName("dropped") DROPPED("dropped"),
}
@Serializable
data class SimklMedia(
val title: String? = null,
val poster: String? = null,
val year: Int? = null,
val runtime: Int? = null,
val ids: Map<String, JsonElement> = emptyMap(),
)
@Serializable
data class SimklEpisodeMapping(
val season: Int? = null,
val episode: Int? = null,
)
@Serializable
data class SimklEpisodeIds(
@SerialName("tvdb_id") val tvdbId: Long? = null,
)
@Serializable
data class SimklEpisode(
val number: Int? = null,
@SerialName("watched_at") val watchedAt: String? = null,
val tvdb: SimklEpisodeMapping? = null,
val ids: SimklEpisodeIds? = null,
)
@Serializable
data class SimklSeason(
val number: Int? = null,
val episodes: List<SimklEpisode> = emptyList(),
)
@Serializable
data class SimklLibraryEntry(
val mediaType: SimklMediaType = SimklMediaType.SHOWS,
@SerialName("added_to_watchlist_at") val addedToWatchlistAt: String? = null,
@SerialName("last_watched_at") val lastWatchedAt: String? = null,
@SerialName("user_rated_at") val userRatedAt: String? = null,
@SerialName("user_rating") val userRating: Int? = null,
val status: SimklListStatus? = null,
@SerialName("last_watched") val lastWatched: String? = null,
@SerialName("next_to_watch") val nextToWatch: String? = null,
@SerialName("watched_episodes_count") val watchedEpisodesCount: Int = 0,
@SerialName("total_episodes_count") val totalEpisodesCount: Int = 0,
@SerialName("not_aired_episodes_count") val notAiredEpisodesCount: Int = 0,
val show: SimklMedia? = null,
val movie: SimklMedia? = null,
@SerialName("anime_type") val animeType: String? = null,
val seasons: List<SimklSeason> = emptyList(),
) {
val media: SimklMedia?
get() = movie ?: show
fun stableKey(): String? = media?.ids?.stableMediaId()?.let { id ->
"${mediaType.apiValue}:$id"
}
}
@Serializable
data class SimklAllItemsResponse(
val shows: List<SimklLibraryEntry>? = null,
val movies: List<SimklLibraryEntry>? = null,
val anime: List<SimklLibraryEntry>? = null,
) {
fun entriesFor(type: SimklMediaType): List<SimklLibraryEntry> = when (type) {
SimklMediaType.SHOWS -> shows
SimklMediaType.MOVIES -> movies
SimklMediaType.ANIME -> anime
}.orEmpty().map { entry -> entry.copy(mediaType = type) }
fun presentTypes(): Set<SimklMediaType> = buildSet {
if (shows != null) add(SimklMediaType.SHOWS)
if (movies != null) add(SimklMediaType.MOVIES)
if (anime != null) add(SimklMediaType.ANIME)
}
}
@Serializable
data class SimklActivitySettings(
val all: String? = null,
)
@Serializable
data class SimklActivityDomain(
val all: String? = null,
@SerialName("rated_at") val ratedAt: String? = null,
val playback: String? = null,
val plantowatch: String? = null,
val watching: String? = null,
val completed: String? = null,
val hold: String? = null,
val dropped: String? = null,
@SerialName("removed_from_list") val removedFromList: String? = null,
)
@Serializable
data class SimklActivities(
val all: String? = null,
val settings: SimklActivitySettings = SimklActivitySettings(),
@SerialName("tv_shows") val tvShows: SimklActivityDomain = SimklActivityDomain(),
val movies: SimklActivityDomain = SimklActivityDomain(),
val anime: SimklActivityDomain = SimklActivityDomain(),
) {
fun domain(type: SimklMediaType): SimklActivityDomain = when (type) {
SimklMediaType.SHOWS -> tvShows
SimklMediaType.MOVIES -> movies
SimklMediaType.ANIME -> anime
}
}
@Serializable
data class SimklPlaybackEpisode(
val season: Int? = null,
val number: Int? = null,
val title: String? = null,
@SerialName("tvdb_season") val tvdbSeason: Int? = null,
@SerialName("tvdb_number") val tvdbNumber: Int? = null,
)
@Serializable
data class SimklPlaybackSession(
val id: Long? = null,
val progress: Double = 0.0,
@SerialName("paused_at") val pausedAt: String? = null,
@SerialName("watched_at") val watchedAt: String? = null,
val type: String? = null,
val episode: SimklPlaybackEpisode? = null,
val show: SimklMedia? = null,
val anime: SimklMedia? = null,
val movie: SimklMedia? = null,
) {
val media: SimklMedia?
get() = movie ?: anime ?: show
val mediaType: SimklMediaType
get() = when {
movie != null -> SimklMediaType.MOVIES
anime != null -> SimklMediaType.ANIME
else -> SimklMediaType.SHOWS
}
}
@Serializable
data class SimklSyncSnapshot(
val schemaVersion: Int = 1,
val isInitialized: Boolean = false,
val watermark: String? = null,
val activities: SimklActivities? = null,
val entries: List<SimklLibraryEntry> = emptyList(),
val playback: List<SimklPlaybackSession> = emptyList(),
val lastSyncedAtEpochMs: Long? = null,
val lastCheckedAtEpochMs: Long? = null,
)
data class SimklSyncUiState(
val snapshot: SimklSyncSnapshot = SimklSyncSnapshot(),
val isLoading: Boolean = false,
val hasLoaded: Boolean = false,
val errorMessage: String? = null,
)
internal fun Map<String, JsonElement>.idValue(key: String): String? =
get(key)?.jsonPrimitive?.content?.trim()?.takeIf(String::isNotBlank)
private fun Map<String, JsonElement>.stableMediaId(): String? {
val keys = listOf("simkl", "imdb", "tmdb", "tvdb", "mal", "anidb", "anilist", "kitsu")
return keys.firstNotNullOfOrNull { key -> idValue(key)?.let { value -> "$key:$value" } }
}

View file

@ -0,0 +1,68 @@
package com.nuvio.app.features.simkl
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
internal data class SimklAllItemsRequest(
val type: SimklMediaType? = null,
val dateFrom: String? = null,
val includeEpisodeDetails: Boolean = false,
val idsOnly: Boolean = false,
)
internal interface SimklSyncRemote {
suspend fun fetchActivities(): SimklActivities
suspend fun fetchAllItems(request: SimklAllItemsRequest): SimklAllItemsResponse
suspend fun fetchPlayback(): List<SimklPlaybackSession>
}
internal class SimklApiSyncRemote(
private val client: SimklApiClient = SimklApi.client,
) : SimklSyncRemote {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
override suspend fun fetchActivities(): SimklActivities =
client.execute(
SimklApiRequest(
method = SimklHttpMethod.GET,
path = "/sync/activities",
),
).body.decode()
override suspend fun fetchAllItems(request: SimklAllItemsRequest): SimklAllItemsResponse {
val path = request.type?.let { type -> "/sync/all-items/${type.apiValue}" }
?: "/sync/all-items"
val query = buildMap {
request.dateFrom?.let { value -> put("date_from", value) }
when {
request.idsOnly -> put("extended", "simkl_ids_only")
request.includeEpisodeDetails -> {
put("extended", "full_anime_seasons")
put("episode_watched_at", "yes")
put("episode_tvdb_id", "yes")
put("include_all_episodes", "original")
}
}
}
return client.execute(
SimklApiRequest(
method = SimklHttpMethod.GET,
path = path,
query = query,
),
).body.decode()
}
override suspend fun fetchPlayback(): List<SimklPlaybackSession> =
client.execute(
SimklApiRequest(
method = SimklHttpMethod.GET,
path = "/sync/playback",
),
).body.decode()
private inline fun <reified T> String.decode(): T = json.decodeFromString(this)
}

View file

@ -0,0 +1,127 @@
package com.nuvio.app.features.simkl
import co.touchlab.kermit.Logger
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.tracking.TrackingProfileStore
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingProviderRegistry
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
object SimklSyncRepository : TrackingProfileStore {
override val providerId: TrackingProviderId = TrackingProviderId.SIMKL
private val log = Logger.withTag("SimklSync")
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val refreshMutex = Mutex()
private val engine = SimklSyncEngine(
remote = SimklApiSyncRemote(),
nowEpochMs = SimklPlatformClock::nowEpochMs,
)
private val _state = MutableStateFlow(SimklSyncUiState())
val state: StateFlow<SimklSyncUiState> = _state.asStateFlow()
private var hasLoaded = false
private var profileGeneration = 0L
init {
TrackingProviderRegistry.registerProfileStore(this)
}
fun ensureLoaded() {
if (hasLoaded) return
hasLoaded = true
val snapshot = SimklSyncStorage.loadPayload()
?.trim()
?.takeIf(String::isNotEmpty)
?.let { payload ->
runCatching { json.decodeFromString<SimklSyncSnapshot>(payload) }
.onFailure { error -> log.w { "Failed to parse Simkl sync snapshot: ${error.message}" } }
.getOrNull()
}
?: SimklSyncSnapshot()
_state.value = SimklSyncUiState(snapshot = snapshot, hasLoaded = true)
}
fun refreshAsync() {
scope.launch { refreshNow() }
}
suspend fun ensureFresh() {
ensureLoaded()
val lastChecked = state.value.snapshot.lastCheckedAtEpochMs
if (lastChecked != null && SimklPlatformClock.nowEpochMs() - lastChecked < CACHE_TTL_MS) return
refreshNow()
}
suspend fun refreshNow() {
ensureLoaded()
refreshMutex.withLock {
if (!SimklAuthRepository.isAuthenticated.value) return
val profileId = ProfileRepository.activeProfileId
val generation = profileGeneration
val previous = _state.value
_state.value = previous.copy(isLoading = true, errorMessage = null)
val result = try {
engine.synchronize(previous.snapshot)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
log.w { "Simkl sync failed: ${error.message}" }
if (generation == profileGeneration && profileId == ProfileRepository.activeProfileId) {
_state.value = previous.copy(
isLoading = false,
hasLoaded = true,
errorMessage = error.message ?: "Unable to sync Simkl",
)
}
return
}
if (generation != profileGeneration || profileId != ProfileRepository.activeProfileId) return
SimklSyncStorage.savePayload(json.encodeToString(result))
_state.value = SimklSyncUiState(
snapshot = result,
hasLoaded = true,
)
}
}
override fun onProfileChanged() {
profileGeneration += 1L
hasLoaded = false
_state.value = SimklSyncUiState()
ensureLoaded()
}
override fun clearLocalState() {
profileGeneration += 1L
hasLoaded = false
_state.value = SimklSyncUiState()
SimklSyncStorage.savePayload("")
}
override fun removeStoredProfile(profileId: Int) {
SimklSyncStorage.removeProfile(profileId)
}
private const val CACHE_TTL_MS = 60_000L
}

View file

@ -0,0 +1,7 @@
package com.nuvio.app.features.simkl
internal expect object SimklSyncStorage {
fun loadPayload(): String?
fun savePayload(payload: String)
fun removeProfile(profileId: Int)
}

View file

@ -38,23 +38,36 @@ data class TrackingProviderDescriptor(
val capabilities: Set<TrackingCapability>,
)
interface TrackingAuthProvider {
val descriptor: TrackingProviderDescriptor
val isAuthenticated: StateFlow<Boolean>
interface TrackingProfileStore {
val providerId: TrackingProviderId
fun ensureLoaded()
fun onProfileChanged()
fun clearLocalState()
fun removeStoredProfile(profileId: Int)
}
interface TrackingAuthProvider : TrackingProfileStore {
val descriptor: TrackingProviderDescriptor
val isAuthenticated: StateFlow<Boolean>
override val providerId: TrackingProviderId
get() = descriptor.id
fun ensureLoaded()
fun handleAuthCallback(url: String): Boolean = false
}
object TrackingProviderRegistry {
private val lock = SynchronizedObject()
private val authProviders = mutableMapOf<TrackingProviderId, TrackingAuthProvider>()
private val profileStores = mutableSetOf<TrackingProfileStore>()
fun register(provider: TrackingAuthProvider) = synchronized(lock) {
authProviders[provider.descriptor.id] = provider
profileStores += provider
}
fun registerProfileStore(store: TrackingProfileStore) = synchronized(lock) {
profileStores += store
}
fun authProvider(id: TrackingProviderId): TrackingAuthProvider? = synchronized(lock) {
@ -84,21 +97,25 @@ object TrackingProviderRegistry {
}
fun onProfileChanged() {
providerSnapshot().forEach(TrackingAuthProvider::onProfileChanged)
profileStoreSnapshot().forEach(TrackingProfileStore::onProfileChanged)
}
fun clearLocalState() {
providerSnapshot().forEach(TrackingAuthProvider::clearLocalState)
profileStoreSnapshot().forEach(TrackingProfileStore::clearLocalState)
}
fun removeStoredProfiles(profileIds: Iterable<Int>) {
val providers = providerSnapshot()
val stores = profileStoreSnapshot()
profileIds.forEach { profileId ->
providers.forEach { provider -> provider.removeStoredProfile(profileId) }
stores.forEach { store -> store.removeStoredProfile(profileId) }
}
}
private fun providerSnapshot(): List<TrackingAuthProvider> = synchronized(lock) {
authProviders.values.toList()
}
private fun profileStoreSnapshot(): List<TrackingProfileStore> = synchronized(lock) {
profileStores.toList()
}
}

View file

@ -0,0 +1,286 @@
package com.nuvio.app.features.simkl
import com.nuvio.app.features.addons.RawHttpResponse
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class SimklSyncEngineTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun `documented all items fixture decodes flexible ids and nullable fields`() {
val response = json.decodeFromString<SimklAllItemsResponse>(ALL_ITEMS_FIXTURE)
val show = response.entriesFor(SimklMediaType.SHOWS).single()
assertEquals("2090", show.media?.ids?.idValue("simkl"))
assertEquals("153021", show.media?.ids?.idValue("tvdb"))
assertEquals(SimklListStatus.WATCHING, show.status)
assertEquals("2026-05-15T00:32:20Z", show.seasons.single().episodes.single().watchedAt)
val movie = response.entriesFor(SimklMediaType.MOVIES).single()
assertEquals("238", movie.media?.ids?.idValue("tmdb"))
assertNull(movie.userRating)
assertEquals(SimklMediaType.MOVIES, movie.mediaType)
}
@Test
fun `initial sync pulls each type sequentially before playback and activities`() = runBlocking {
val remote = ScriptedRemote(
Step.AllItems(SimklMediaType.SHOWS, responseOf(entry(SimklMediaType.SHOWS, "1"))),
Step.AllItems(SimklMediaType.MOVIES, responseOf(entry(SimklMediaType.MOVIES, "2"))),
Step.AllItems(SimklMediaType.ANIME, responseOf(entry(SimklMediaType.ANIME, "3"))),
Step.Playback(listOf(playback("2"))),
Step.Activities(activities(all = "v1")),
)
val result = SimklSyncEngine(remote) { 500L }.synchronize(SimklSyncSnapshot())
assertTrue(result.isInitialized)
assertEquals("v1", result.watermark)
assertEquals(listOf("1", "2", "3"), result.entries.mapNotNull { it.media?.ids?.idValue("simkl") })
assertEquals(1, result.playback.size)
assertEquals(500L, result.lastSyncedAtEpochMs)
assertTrue(remote.isExhausted)
assertTrue(remote.allItemsRequests.all { request ->
request.dateFrom == null && !request.includeEpisodeDetails && !request.idsOnly
})
}
@Test
fun `unchanged activities gate avoids library and playback calls`() = runBlocking {
val current = SimklSyncSnapshot(
isInitialized = true,
watermark = "v1",
activities = activities(all = "v1"),
entries = listOf(entry(SimklMediaType.SHOWS, "1")),
lastCheckedAtEpochMs = 10L,
)
val remote = ScriptedRemote(Step.Activities(activities(all = "v1")))
val result = SimklSyncEngine(remote) { 20L }.synchronize(current)
assertEquals(current.entries, result.entries)
assertEquals(20L, result.lastCheckedAtEpochMs)
assertTrue(remote.isExhausted)
}
@Test
fun `delta merge reconciles removals and replaces changed playback atomically`() = runBlocking {
val previousActivities = activities(all = "v1", removed = "r1", playback = "p1")
val current = SimklSyncSnapshot(
isInitialized = true,
watermark = "v1",
activities = previousActivities,
entries = listOf(
entry(SimklMediaType.SHOWS, "1"),
entry(SimklMediaType.MOVIES, "2", SimklListStatus.PLAN_TO_WATCH),
),
playback = listOf(playback("1")),
)
val changedActivities = activities(all = "v2", removed = "r2", playback = "p2")
val delta = SimklAllItemsResponse(
shows = listOf(entry(SimklMediaType.SHOWS, "3")),
movies = listOf(entry(SimklMediaType.MOVIES, "2", SimklListStatus.DROPPED)),
)
val authoritative = SimklAllItemsResponse(
shows = listOf(entry(SimklMediaType.SHOWS, "3")),
movies = listOf(entry(SimklMediaType.MOVIES, "2")),
anime = emptyList(),
)
val remote = ScriptedRemote(
Step.Activities(changedActivities),
Step.AllItems(null, delta),
Step.AllItems(null, authoritative),
Step.Playback(listOf(playback("3"))),
)
val result = SimklSyncEngine(remote) { 900L }.synchronize(current)
assertEquals(setOf("2", "3"), result.entries.mapNotNull { it.media?.ids?.idValue("simkl") }.toSet())
assertEquals(
SimklListStatus.DROPPED,
result.entries.single { it.media?.ids?.idValue("simkl") == "2" }.status,
)
assertEquals("3", result.playback.single().media?.ids?.idValue("simkl"))
assertEquals("v2", result.watermark)
assertTrue(remote.allItemsRequests[0].includeEpisodeDetails)
assertEquals("v1", remote.allItemsRequests[0].dateFrom)
assertTrue(remote.allItemsRequests[1].idsOnly)
assertTrue(remote.isExhausted)
}
@Test
fun `failed delta leaves the caller snapshot unchanged`() = runBlocking {
val current = SimklSyncSnapshot(
isInitialized = true,
watermark = "v1",
activities = activities(all = "v1"),
entries = listOf(entry(SimklMediaType.SHOWS, "1")),
)
val remote = ScriptedRemote(
Step.Activities(activities(all = "v2")),
Step.Failure(IllegalStateException("network")),
)
assertFailsWith<IllegalStateException> {
SimklSyncEngine(remote) { 1_000L }.synchronize(current)
}
assertEquals("v1", current.watermark)
assertEquals("1", current.entries.single().media?.ids?.idValue("simkl"))
}
@Test
fun `remote keeps initial pull minimal and adds rich flags only to dated delta`() = runBlocking {
var now = 0L
val urls = mutableListOf<String>()
val engine = SimklHttpEngine { _, url, _, _ ->
urls += url
RawHttpResponse(200, "", url, "{}", emptyMap())
}
val client = SimklApiClient(
engine = engine,
accessToken = { "token" },
onUnauthorized = {},
nowEpochMs = { now },
sleep = { duration -> now += duration },
retryJitterMs = { 0L },
)
val remote = SimklApiSyncRemote(client)
remote.fetchAllItems(SimklAllItemsRequest(type = SimklMediaType.SHOWS))
remote.fetchAllItems(
SimklAllItemsRequest(
dateFrom = "2026-05-08T14:23:11Z",
includeEpisodeDetails = true,
),
)
remote.fetchAllItems(SimklAllItemsRequest(idsOnly = true))
assertFalse("date_from=" in urls[0])
assertFalse("extended=" in urls[0])
assertTrue("date_from=2026-05-08T14%3A23%3A11Z" in urls[1])
assertTrue("extended=full_anime_seasons" in urls[1])
assertTrue("episode_watched_at=yes" in urls[1])
assertTrue("include_all_episodes=original" in urls[1])
assertTrue("extended=simkl_ids_only" in urls[2])
}
private sealed interface Step {
data class Activities(val value: SimklActivities) : Step
data class AllItems(val type: SimklMediaType?, val value: SimklAllItemsResponse) : Step
data class Playback(val value: List<SimklPlaybackSession>) : Step
data class Failure(val error: Throwable) : Step
}
private class ScriptedRemote(vararg steps: Step) : SimklSyncRemote {
private val remaining = steps.toMutableList()
val allItemsRequests = mutableListOf<SimklAllItemsRequest>()
val isExhausted: Boolean get() = remaining.isEmpty()
override suspend fun fetchActivities(): SimklActivities = when (val step = next()) {
is Step.Activities -> step.value
is Step.Failure -> throw step.error
else -> error("Expected activities, got $step")
}
override suspend fun fetchAllItems(request: SimklAllItemsRequest): SimklAllItemsResponse {
allItemsRequests += request
return when (val step = next()) {
is Step.AllItems -> {
assertEquals(step.type, request.type)
step.value
}
is Step.Failure -> throw step.error
else -> error("Expected all-items, got $step")
}
}
override suspend fun fetchPlayback(): List<SimklPlaybackSession> = when (val step = next()) {
is Step.Playback -> step.value
is Step.Failure -> throw step.error
else -> error("Expected playback, got $step")
}
private fun next(): Step = remaining.removeAt(0)
}
private companion object {
fun entry(
type: SimklMediaType,
id: String,
status: SimklListStatus = SimklListStatus.WATCHING,
) = SimklLibraryEntry(
mediaType = type,
status = status,
show = if (type == SimklMediaType.MOVIES) null else media(id),
movie = if (type == SimklMediaType.MOVIES) media(id) else null,
)
fun media(id: String) = SimklMedia(
title = "Title $id",
ids = buildJsonObject { put("simkl", id.toLong()) },
)
fun responseOf(entry: SimklLibraryEntry): SimklAllItemsResponse = when (entry.mediaType) {
SimklMediaType.SHOWS -> SimklAllItemsResponse(shows = listOf(entry))
SimklMediaType.MOVIES -> SimklAllItemsResponse(movies = listOf(entry))
SimklMediaType.ANIME -> SimklAllItemsResponse(anime = listOf(entry))
}
fun playback(id: String) = SimklPlaybackSession(
id = id.toLong(),
progress = 42.0,
movie = media(id),
)
fun activities(
all: String,
removed: String = "removed",
playback: String = "playback",
): SimklActivities {
val domain = SimklActivityDomain(
all = all,
removedFromList = removed,
playback = playback,
)
return SimklActivities(all = all, tvShows = domain, movies = domain, anime = domain)
}
const val ALL_ITEMS_FIXTURE = """
{
"shows": [{
"last_watched_at": "2026-05-15T00:35:15Z",
"user_rating": null,
"status": "watching",
"last_watched": "S01E01",
"watched_episodes_count": 1,
"total_episodes_count": 177,
"show": {
"title": "The Walking Dead",
"year": 2010,
"ids": {"simkl": 2090, "imdb": "tt1520211", "tvdb": "153021"}
},
"seasons": [{"number": 1, "episodes": [{"number": 1, "watched_at": "2026-05-15T00:32:20Z"}]}]
}],
"movies": [{
"user_rating": null,
"status": "completed",
"movie": {
"title": "The Godfather",
"year": 1972,
"ids": {"simkl": 53434, "imdb": "tt0068646", "tmdb": "238"}
}
}]
}
"""
}
}

View file

@ -54,6 +54,7 @@ internal actual object PlatformLocalAccountDataCleaner {
"mdblist_use_audience",
"trakt_auth_payload",
"simkl_auth_metadata",
"simkl_sync_snapshot",
"trakt_library_payload",
"trakt_settings_payload",
"library_display_settings_payload",

View file

@ -0,0 +1,19 @@
package com.nuvio.app.features.simkl
import com.nuvio.app.core.storage.ProfileScopedKey
import platform.Foundation.NSUserDefaults
internal actual object SimklSyncStorage {
private const val PAYLOAD_KEY = "simkl_sync_snapshot"
actual fun loadPayload(): String? =
NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(PAYLOAD_KEY))
actual fun savePayload(payload: String) {
NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(PAYLOAD_KEY))
}
actual fun removeProfile(profileId: Int) {
NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(PAYLOAD_KEY, profileId))
}
}