mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-03 18:16:15 +00:00
feat(simkl): add atomic sync store
This commit is contained in:
parent
f7c17e656e
commit
615098e976
14 changed files with 880 additions and 8 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
)
|
||||
|
|
@ -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" } }
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
}
|
||||
}]
|
||||
}
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue