From 615098e976d7f14bfde53faf3358d3545f9a6dc3 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:22:22 +0530 Subject: [PATCH] feat(simkl): add atomic sync store --- .../kotlin/com/nuvio/app/MainActivity.kt | 2 + ...PlatformLocalAccountDataCleaner.android.kt | 1 + .../simkl/SimklSyncStorage.android.kt | 29 ++ .../tracking/TrackingProviderBootstrap.kt | 2 + .../app/features/simkl/SimklAuthRepository.kt | 3 + .../app/features/simkl/SimklSyncEngine.kt | 117 +++++++ .../app/features/simkl/SimklSyncModels.kt | 193 ++++++++++++ .../app/features/simkl/SimklSyncRemote.kt | 68 +++++ .../app/features/simkl/SimklSyncRepository.kt | 127 ++++++++ .../app/features/simkl/SimklSyncStorage.kt | 7 + .../app/features/tracking/TrackingProvider.kt | 33 +- .../app/features/simkl/SimklSyncEngineTest.kt | 286 ++++++++++++++++++ .../PlatformLocalAccountDataCleaner.ios.kt | 1 + .../features/simkl/SimklSyncStorage.ios.kt | 19 ++ 14 files changed, 880 insertions(+), 8 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklSyncStorage.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncEngine.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncModels.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRemote.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncStorage.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSyncEngineTest.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/simkl/SimklSyncStorage.ios.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index 2127e8b2c..0cbc68560 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -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) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt index c12fe5af9..80052d89c 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt @@ -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", diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklSyncStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklSyncStorage.android.kt new file mode 100644 index 000000000..798cf20d1 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklSyncStorage.android.kt @@ -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() + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/tracking/TrackingProviderBootstrap.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/tracking/TrackingProviderBootstrap.kt index a35fddc0b..e510aa00c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/tracking/TrackingProviderBootstrap.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/tracking/TrackingProviderBootstrap.kt @@ -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 } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthRepository.kt index 337d69687..6a373b8fb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthRepository.kt @@ -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) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncEngine.kt new file mode 100644 index 000000000..d7c846e66 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncEngine.kt @@ -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, + delta: SimklAllItemsResponse, +): List { + 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, + authoritative: SimklAllItemsResponse, +): List { + 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( + { entry -> entry.mediaType.ordinal }, + { entry -> entry.media?.title.orEmpty().lowercase() }, + { entry -> entry.stableKey().orEmpty() }, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncModels.kt new file mode 100644 index 000000000..a31f531b6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncModels.kt @@ -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 = 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 = 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 = 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? = null, + val movies: List? = null, + val anime: List? = null, +) { + fun entriesFor(type: SimklMediaType): List = when (type) { + SimklMediaType.SHOWS -> shows + SimklMediaType.MOVIES -> movies + SimklMediaType.ANIME -> anime + }.orEmpty().map { entry -> entry.copy(mediaType = type) } + + fun presentTypes(): Set = 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 = emptyList(), + val playback: List = 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.idValue(key: String): String? = + get(key)?.jsonPrimitive?.content?.trim()?.takeIf(String::isNotBlank) + +private fun Map.stableMediaId(): String? { + val keys = listOf("simkl", "imdb", "tmdb", "tvdb", "mal", "anidb", "anilist", "kitsu") + return keys.firstNotNullOfOrNull { key -> idValue(key)?.let { value -> "$key:$value" } } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRemote.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRemote.kt new file mode 100644 index 000000000..489daf161 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRemote.kt @@ -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 +} + +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 = + client.execute( + SimklApiRequest( + method = SimklHttpMethod.GET, + path = "/sync/playback", + ), + ).body.decode() + + private inline fun String.decode(): T = json.decodeFromString(this) +} 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 new file mode 100644 index 000000000..026791e1b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt @@ -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 = _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(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 +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncStorage.kt new file mode 100644 index 000000000..9f1f71e13 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncStorage.kt @@ -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) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingProvider.kt index c12bf602a..43ce3299a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingProvider.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingProvider.kt @@ -38,23 +38,36 @@ data class TrackingProviderDescriptor( val capabilities: Set, ) -interface TrackingAuthProvider { - val descriptor: TrackingProviderDescriptor - val isAuthenticated: StateFlow +interface TrackingProfileStore { + val providerId: TrackingProviderId - fun ensureLoaded() fun onProfileChanged() fun clearLocalState() fun removeStoredProfile(profileId: Int) +} + +interface TrackingAuthProvider : TrackingProfileStore { + val descriptor: TrackingProviderDescriptor + val isAuthenticated: StateFlow + 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() + private val profileStores = mutableSetOf() 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) { - 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 = synchronized(lock) { authProviders.values.toList() } + + private fun profileStoreSnapshot(): List = synchronized(lock) { + profileStores.toList() + } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSyncEngineTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSyncEngineTest.kt new file mode 100644 index 000000000..89a0db689 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSyncEngineTest.kt @@ -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(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 { + 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() + 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) : Step + data class Failure(val error: Throwable) : Step + } + + private class ScriptedRemote(vararg steps: Step) : SimklSyncRemote { + private val remaining = steps.toMutableList() + val allItemsRequests = mutableListOf() + 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 = 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"} + } + }] + } + """ + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt index 536492d87..9980e3bd0 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt @@ -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", diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/simkl/SimklSyncStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/simkl/SimklSyncStorage.ios.kt new file mode 100644 index 000000000..cbf92f285 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/simkl/SimklSyncStorage.ios.kt @@ -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)) + } +}