From 857311cce76710a8136521591919843c54d1da68 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:37:58 +0530 Subject: [PATCH 01/59] refactor(tracking): add provider-neutral foundation --- .../com/nuvio/app/features/home/HomeScreen.kt | 2 +- .../app/features/library/LibraryModels.kt | 1 + .../features/settings/TraktSettingsPage.kt | 4 +- .../app/features/tracking/TrackingProvider.kt | 79 +++++++++++++++++++ .../app/features/tracking/TrackingSources.kt | 54 +++++++++++++ .../app/features/trakt/TraktAuthRepository.kt | 36 +++++++-- .../features/trakt/TraktSettingsRepository.kt | 27 +++---- .../app/features/watched/WatchedRepository.kt | 76 +++++++++++++----- .../ContinueWatchingEnrichmentCache.kt | 2 +- .../watchprogress/WatchProgressRepository.kt | 4 +- .../WatchProgressSourceCoordinator.kt | 4 +- .../nuvio/app/features/home/HomeScreenTest.kt | 2 +- .../features/tracking/TrackingSourcesTest.kt | 45 +++++++++++ .../app/features/watched/WatchedModelsTest.kt | 2 +- .../features/watched/WatchedRepositoryTest.kt | 19 ++++- .../ContinueWatchingEnrichmentCacheTest.kt | 2 +- .../WatchProgressSourceCoordinatorTest.kt | 2 +- 17 files changed, 307 insertions(+), 54 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingProvider.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingSources.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingSourcesTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index af37817b..be2bca10 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -51,7 +51,7 @@ import com.nuvio.app.features.home.components.HomeContinueWatchingSectionBottomP import com.nuvio.app.features.home.components.ContinueWatchingLayout import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL import com.nuvio.app.features.trakt.TraktSettingsRepository -import com.nuvio.app.features.trakt.WatchProgressSource +import com.nuvio.app.features.tracking.WatchProgressSource import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watched.WatchedRepository diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt index c1871101..f8b0a8cf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt @@ -36,6 +36,7 @@ data class LibrarySection( enum class LibrarySourceMode { LOCAL, TRAKT, + SIMKL, } data class LibraryUiState( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt index 4f8420de..3bdb0ebc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt @@ -48,7 +48,7 @@ import com.nuvio.app.features.trakt.TraktContinueWatchingDaysOptions import com.nuvio.app.features.trakt.MoreLikeThisSourcePreference import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.trakt.TraktSettingsUiState -import com.nuvio.app.features.trakt.WatchProgressSource +import com.nuvio.app.features.tracking.WatchProgressSource import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap import com.nuvio.app.features.trakt.traktBrandPainter @@ -355,6 +355,7 @@ private fun librarySourceModeLabel(source: LibrarySourceMode): String = when (source) { LibrarySourceMode.TRAKT -> stringResource(Res.string.trakt_library_source_trakt) LibrarySourceMode.LOCAL -> stringResource(Res.string.trakt_library_source_nuvio) + LibrarySourceMode.SIMKL -> "Simkl" } @Composable @@ -362,6 +363,7 @@ private fun watchProgressSourceLabel(source: WatchProgressSource): String = when (source) { WatchProgressSource.TRAKT -> stringResource(Res.string.trakt_watch_progress_source_trakt) WatchProgressSource.NUVIO_SYNC -> stringResource(Res.string.trakt_watch_progress_source_nuvio) + WatchProgressSource.SIMKL -> "Simkl" } @Composable 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 new file mode 100644 index 00000000..49916f09 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingProvider.kt @@ -0,0 +1,79 @@ +package com.nuvio.app.features.tracking + +import kotlinx.coroutines.flow.StateFlow +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized + +enum class TrackingProviderId( + val storageId: String, +) { + TRAKT("trakt"), + SIMKL("simkl"); + + companion object { + fun fromStorage(value: String?): TrackingProviderId? = + entries.firstOrNull { provider -> + provider.storageId.equals(value?.trim(), ignoreCase = true) || + provider.name.equals(value?.trim(), ignoreCase = true) + } + } +} + +enum class TrackingCapability { + AUTHENTICATION, + LIBRARY_READ, + LIBRARY_WRITE, + WATCHED_READ, + WATCHED_WRITE, + PROGRESS_READ, + PROGRESS_WRITE, + SCROBBLE, + COMMENTS, + RECOMMENDATIONS, +} + +data class TrackingProviderDescriptor( + val id: TrackingProviderId, + val displayName: String, + val capabilities: Set, +) + +interface TrackingAuthProvider { + val descriptor: TrackingProviderDescriptor + val isAuthenticated: StateFlow + + fun ensureLoaded() + fun onProfileChanged() + fun clearLocalState() +} + +object TrackingProviderRegistry { + private val lock = SynchronizedObject() + private val authProviders = mutableMapOf() + + fun register(provider: TrackingAuthProvider) = synchronized(lock) { + authProviders[provider.descriptor.id] = provider + } + + fun authProvider(id: TrackingProviderId): TrackingAuthProvider? = synchronized(lock) { + authProviders[id] + } + + fun isAuthenticated(id: TrackingProviderId): Boolean = + authProvider(id)?.also(TrackingAuthProvider::ensureLoaded)?.isAuthenticated?.value == true + + fun connectedProviderIds(): Set = + providerSnapshot() + .onEach(TrackingAuthProvider::ensureLoaded) + .filterTo(linkedSetOf()) { provider -> provider.isAuthenticated.value } + .mapTo(linkedSetOf()) { provider -> provider.descriptor.id } + + fun providersWith(capability: TrackingCapability): List = + providerSnapshot() + .filter { provider -> capability in provider.descriptor.capabilities } + .sortedBy { provider -> provider.descriptor.id.ordinal } + + private fun providerSnapshot(): List = synchronized(lock) { + authProviders.values.toList() + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingSources.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingSources.kt new file mode 100644 index 00000000..8e0bf121 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingSources.kt @@ -0,0 +1,54 @@ +package com.nuvio.app.features.tracking + +import com.nuvio.app.features.library.LibrarySourceMode +import kotlinx.serialization.Serializable + +@Serializable +enum class WatchProgressSource { + TRAKT, + SIMKL, + NUVIO_SYNC; + + val providerId: TrackingProviderId? + get() = when (this) { + TRAKT -> TrackingProviderId.TRAKT + SIMKL -> TrackingProviderId.SIMKL + NUVIO_SYNC -> null + } + + companion object { + fun fromStorage(value: String?): WatchProgressSource = + entries.firstOrNull { it.name == value } ?: DEFAULT_WATCH_PROGRESS_SOURCE + } +} + +val DEFAULT_WATCH_PROGRESS_SOURCE: WatchProgressSource = WatchProgressSource.TRAKT +val DEFAULT_LIBRARY_SOURCE_MODE: LibrarySourceMode = LibrarySourceMode.TRAKT + +fun librarySourceModeFromStorage(value: String?): LibrarySourceMode = + LibrarySourceMode.entries.firstOrNull { it.name == value } ?: DEFAULT_LIBRARY_SOURCE_MODE + +val LibrarySourceMode.providerId: TrackingProviderId? + get() = when (this) { + LibrarySourceMode.LOCAL -> null + LibrarySourceMode.TRAKT -> TrackingProviderId.TRAKT + LibrarySourceMode.SIMKL -> TrackingProviderId.SIMKL + } + +fun effectiveWatchProgressSource( + requestedSource: WatchProgressSource, + isProviderAuthenticated: (TrackingProviderId) -> Boolean, +): WatchProgressSource { + val providerId = requestedSource.providerId ?: return WatchProgressSource.NUVIO_SYNC + return requestedSource.takeIf { isProviderAuthenticated(providerId) } + ?: WatchProgressSource.NUVIO_SYNC +} + +fun effectiveLibrarySourceMode( + requestedSource: LibrarySourceMode, + isProviderAuthenticated: (TrackingProviderId) -> Boolean, +): LibrarySourceMode { + val providerId = requestedSource.providerId ?: return LibrarySourceMode.LOCAL + return requestedSource.takeIf { isProviderAuthenticated(providerId) } + ?: LibrarySourceMode.LOCAL +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt index 20fa9eb5..e2c48d8d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt @@ -5,6 +5,11 @@ import com.nuvio.app.features.addons.httpGetTextWithHeaders import com.nuvio.app.features.addons.httpPostJsonWithHeaders import com.nuvio.app.features.addons.httpRequestRaw import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.tracking.TrackingAuthProvider +import com.nuvio.app.features.tracking.TrackingCapability +import com.nuvio.app.features.tracking.TrackingProviderDescriptor +import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.TrackingProviderRegistry import io.ktor.http.Url import io.ktor.http.encodeURLParameter import kotlinx.coroutines.CancellationException @@ -28,7 +33,7 @@ import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.StringResource import kotlinx.coroutines.runBlocking -object TraktAuthRepository { +object TraktAuthRepository : TrackingAuthProvider { private const val BASE_URL = "https://api.trakt.tv" private const val AUTHORIZE_URL = "https://trakt.tv/oauth/authorize" private const val API_VERSION = "2" @@ -45,21 +50,42 @@ object TraktAuthRepository { val uiState: StateFlow = _uiState.asStateFlow() private val _isAuthenticated = MutableStateFlow(false) - val isAuthenticated: StateFlow = _isAuthenticated.asStateFlow() + override val isAuthenticated: StateFlow = _isAuthenticated.asStateFlow() + + override val descriptor = TrackingProviderDescriptor( + id = TrackingProviderId.TRAKT, + displayName = "Trakt", + capabilities = setOf( + TrackingCapability.AUTHENTICATION, + TrackingCapability.LIBRARY_READ, + TrackingCapability.LIBRARY_WRITE, + TrackingCapability.WATCHED_READ, + TrackingCapability.WATCHED_WRITE, + TrackingCapability.PROGRESS_READ, + TrackingCapability.PROGRESS_WRITE, + TrackingCapability.SCROBBLE, + TrackingCapability.COMMENTS, + TrackingCapability.RECOMMENDATIONS, + ), + ) + + init { + TrackingProviderRegistry.register(this) + } private var hasLoaded = false private var authState = TraktAuthState() - fun ensureLoaded() { + override fun ensureLoaded() { if (hasLoaded) return loadFromDisk() } - fun onProfileChanged() { + override fun onProfileChanged() { loadFromDisk() } - fun clearLocalState() { + override fun clearLocalState() { hasLoaded = false authState = TraktAuthState() publish() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt index c696b2ac..0fce69c1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt @@ -12,6 +12,16 @@ import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +typealias WatchProgressSource = com.nuvio.app.features.tracking.WatchProgressSource + +val DEFAULT_WATCH_PROGRESS_SOURCE: WatchProgressSource = + com.nuvio.app.features.tracking.DEFAULT_WATCH_PROGRESS_SOURCE +val DEFAULT_LIBRARY_SOURCE_MODE: LibrarySourceMode = + com.nuvio.app.features.tracking.DEFAULT_LIBRARY_SOURCE_MODE + +fun librarySourceModeFromStorage(value: String?): LibrarySourceMode = + com.nuvio.app.features.tracking.librarySourceModeFromStorage(value) + const val TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL = 0 const val TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP = 60 const val TRAKT_MIN_CONTINUE_WATCHING_DAYS_CAP = 7 @@ -27,23 +37,6 @@ val TraktContinueWatchingDaysOptions: List = listOf( TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL, ) -@Serializable -enum class WatchProgressSource { - TRAKT, - NUVIO_SYNC; - - companion object { - fun fromStorage(value: String?): WatchProgressSource = - entries.firstOrNull { it.name == value } ?: DEFAULT_WATCH_PROGRESS_SOURCE - } -} - -val DEFAULT_WATCH_PROGRESS_SOURCE: WatchProgressSource = WatchProgressSource.TRAKT -val DEFAULT_LIBRARY_SOURCE_MODE: LibrarySourceMode = LibrarySourceMode.TRAKT - -fun librarySourceModeFromStorage(value: String?): LibrarySourceMode = - LibrarySourceMode.entries.firstOrNull { it.name == value } ?: DEFAULT_LIBRARY_SOURCE_MODE - @Serializable enum class MoreLikeThisSourcePreference { TRAKT, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt index 27ee2076..7c05b9a1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt @@ -8,7 +8,7 @@ import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktSettingsRepository -import com.nuvio.app.features.trakt.WatchProgressSource +import com.nuvio.app.features.tracking.WatchProgressSource import com.nuvio.app.features.trakt.shouldUseTraktProgress import com.nuvio.app.features.watching.sync.SupabaseWatchedSyncAdapter import com.nuvio.app.features.watching.sync.TraktWatchedSyncAdapter @@ -65,9 +65,11 @@ internal fun watchedItemsForSource( source: WatchProgressSource, nuvioItems: Collection, traktItems: Collection, + simklItems: Collection = emptyList(), ): Collection = when (source) { WatchProgressSource.NUVIO_SYNC -> nuvioItems WatchProgressSource.TRAKT -> traktItems + WatchProgressSource.SIMKL -> simklItems } internal fun shouldPersistWatchedSource(source: WatchProgressSource): Boolean = @@ -77,11 +79,13 @@ internal fun replaceWatchedItemsForSource( source: WatchProgressSource, nuvioItems: MutableMap, traktItems: MutableMap, + simklItems: MutableMap, replacement: Map, ) { val target = when (source) { WatchProgressSource.NUVIO_SYNC -> nuvioItems WatchProgressSource.TRAKT -> traktItems + WatchProgressSource.SIMKL -> simklItems } target.clear() target.putAll(replacement) @@ -120,12 +124,16 @@ object WatchedRepository { private var sourceGeneration: Long = 0L private var nuvioItemsByKey: MutableMap = mutableMapOf() private var traktItemsByKey: MutableMap = mutableMapOf() + private var simklItemsByKey: MutableMap = mutableMapOf() private var nuvioFullyWatchedSeriesKeys: Set = emptySet() private var traktFullyWatchedSeriesKeys: Set = emptySet() + private var simklFullyWatchedSeriesKeys: Set = emptySet() private var nuvioHasLoaded: Boolean = false private var traktHasLoaded: Boolean = false + private var simklHasLoaded: Boolean = false private var nuvioHasLoadedRemote: Boolean = false private var traktHasLoadedRemote: Boolean = false + private var simklHasLoadedRemote: Boolean = false private var nuvioDirtyWatchedKeys: MutableSet = mutableSetOf() private var lastSuccessfulPushEpochMs: Long = 0L private var deltaCursorEventId: Long = 0L @@ -167,12 +175,16 @@ object WatchedRepository { sourceGeneration += 1L nuvioItemsByKey.clear() traktItemsByKey.clear() + simklItemsByKey.clear() nuvioFullyWatchedSeriesKeys = emptySet() traktFullyWatchedSeriesKeys = emptySet() + simklFullyWatchedSeriesKeys = emptySet() nuvioHasLoaded = false traktHasLoaded = false + simklHasLoaded = false nuvioHasLoadedRemote = false traktHasLoadedRemote = false + simklHasLoadedRemote = false nuvioDirtyWatchedKeys.clear() lastSuccessfulPushEpochMs = 0L deltaCursorEventId = 0L @@ -189,12 +201,16 @@ object WatchedRepository { hasLoaded = true nuvioItemsByKey.clear() traktItemsByKey.clear() + simklItemsByKey.clear() nuvioFullyWatchedSeriesKeys = emptySet() traktFullyWatchedSeriesKeys = emptySet() + simklFullyWatchedSeriesKeys = emptySet() nuvioHasLoaded = true traktHasLoaded = false + simklHasLoaded = false nuvioHasLoadedRemote = false traktHasLoadedRemote = false + simklHasLoadedRemote = false nuvioDirtyWatchedKeys.clear() val payload = WatchedStorage.loadPayload(profileId).orEmpty().trim() @@ -232,13 +248,20 @@ object WatchedRepository { private fun activateEffectiveSource(source: WatchProgressSource): WatchProgressSource { if (activeSource == source) return source - if (source == WatchProgressSource.TRAKT) { - traktItemsByKey.clear() - traktFullyWatchedSeriesKeys = emptySet() - traktHasLoaded = false - traktHasLoadedRemote = false - } else { - nuvioHasLoadedRemote = false + when (source) { + WatchProgressSource.TRAKT -> { + traktItemsByKey.clear() + traktFullyWatchedSeriesKeys = emptySet() + traktHasLoaded = false + traktHasLoadedRemote = false + } + WatchProgressSource.SIMKL -> { + simklItemsByKey.clear() + simklFullyWatchedSeriesKeys = emptySet() + simklHasLoaded = false + simklHasLoadedRemote = false + } + WatchProgressSource.NUVIO_SYNC -> nuvioHasLoadedRemote = false } activeSource = source sourceGeneration += 1L @@ -323,23 +346,25 @@ object WatchedRepository { } } return try { - if (effectiveSource == WatchProgressSource.TRAKT) { - pullSnapshotFromAdapter( + when (effectiveSource) { + WatchProgressSource.TRAKT -> pullSnapshotFromAdapter( adapter = traktSyncAdapter, operation = operation, profileId = profileId, resetDeltaState = true, ) - } else if (forceSnapshot) { - refreshNuvioSnapshot( - operation = operation, - profileId = profileId, - ) - } else { - pullSupabaseDeltaFromServer( - operation = operation, - profileId = profileId, - ) + WatchProgressSource.SIMKL -> false + WatchProgressSource.NUVIO_SYNC -> if (forceSnapshot) { + refreshNuvioSnapshot( + operation = operation, + profileId = profileId, + ) + } else { + pullSupabaseDeltaFromServer( + operation = operation, + profileId = profileId, + ) + } } } catch (error: CancellationException) { throw error @@ -403,6 +428,7 @@ object WatchedRepository { source = operation.sourceOperation.source, nuvioItems = nuvioItemsByKey, traktItems = traktItemsByKey, + simklItems = simklItemsByKey, replacement = mergedSnapshot.items, ) when (operation.sourceOperation.source) { @@ -419,6 +445,10 @@ object WatchedRepository { traktHasLoaded = true traktHasLoadedRemote = true } + WatchProgressSource.SIMKL -> { + simklHasLoaded = true + simklHasLoadedRemote = true + } } publish() if (shouldPersistWatchedSource(operation.sourceOperation.source)) { @@ -604,12 +634,14 @@ object WatchedRepository { when (source) { WatchProgressSource.NUVIO_SYNC -> nuvioItemsByKey WatchProgressSource.TRAKT -> traktItemsByKey + WatchProgressSource.SIMKL -> simklItemsByKey } private fun fullyWatchedSeriesKeysForSource(source: WatchProgressSource): Set = when (source) { WatchProgressSource.NUVIO_SYNC -> nuvioFullyWatchedSeriesKeys WatchProgressSource.TRAKT -> traktFullyWatchedSeriesKeys + WatchProgressSource.SIMKL -> simklFullyWatchedSeriesKeys } private fun setFullyWatchedSeriesKeysForSource( @@ -619,6 +651,7 @@ object WatchedRepository { when (source) { WatchProgressSource.NUVIO_SYNC -> nuvioFullyWatchedSeriesKeys = keys WatchProgressSource.TRAKT -> traktFullyWatchedSeriesKeys = keys + WatchProgressSource.SIMKL -> simklFullyWatchedSeriesKeys = keys } } @@ -626,6 +659,7 @@ object WatchedRepository { when (source) { WatchProgressSource.NUVIO_SYNC -> nuvioHasLoaded WatchProgressSource.TRAKT -> traktHasLoaded + WatchProgressSource.SIMKL -> simklHasLoaded } fun toggleWatched(item: WatchedItem) { @@ -872,6 +906,7 @@ object WatchedRepository { source = activeSource, nuvioItems = nuvioItemsByKey.values, traktItems = traktItemsByKey.values, + simklItems = simklItemsByKey.values, ) .map(WatchedItem::normalizedMarkedAt) .sortedByDescending { it.markedAtEpochMs } @@ -885,6 +920,7 @@ object WatchedRepository { hasLoadedRemoteItems = when (activeSource) { WatchProgressSource.NUVIO_SYNC -> nuvioHasLoadedRemote WatchProgressSource.TRAKT -> traktHasLoadedRemote + WatchProgressSource.SIMKL -> simklHasLoadedRemote }, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt index 2082b4a9..d746bc5a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt @@ -1,7 +1,7 @@ package com.nuvio.app.features.watchprogress import com.nuvio.app.core.storage.ProfileScopedKey -import com.nuvio.app.features.trakt.WatchProgressSource +import com.nuvio.app.features.tracking.WatchProgressSource import kotlinx.atomicfu.locks.SynchronizedObject import kotlinx.atomicfu.locks.synchronized import kotlinx.coroutines.flow.MutableStateFlow diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index 578f0d83..b7bbfb68 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -14,7 +14,7 @@ import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktProgressRepository import com.nuvio.app.features.trakt.TraktSettingsRepository -import com.nuvio.app.features.trakt.WatchProgressSource +import com.nuvio.app.features.tracking.WatchProgressSource import com.nuvio.app.features.trakt.effectiveWatchProgressSource import com.nuvio.app.features.trakt.isTraktCompatibleId import com.nuvio.app.features.trakt.resolveEffectiveContentId @@ -425,6 +425,8 @@ object WatchProgressRepository { operationGeneration = operationGeneration, force = force, ) + + WatchProgressSource.SIMKL -> false } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt index 69678ca0..6668e3e9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt @@ -4,10 +4,10 @@ import co.touchlab.kermit.Logger import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState import com.nuvio.app.features.profiles.ProfileRepository -import com.nuvio.app.features.trakt.DEFAULT_WATCH_PROGRESS_SOURCE +import com.nuvio.app.features.tracking.DEFAULT_WATCH_PROGRESS_SOURCE import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktSettingsRepository -import com.nuvio.app.features.trakt.WatchProgressSource +import com.nuvio.app.features.tracking.WatchProgressSource import com.nuvio.app.features.trakt.effectiveWatchProgressSource import com.nuvio.app.features.watched.WatchedRepository import kotlinx.atomicfu.atomic diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt index 5ad99e0f..3323d3bf 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt @@ -19,7 +19,7 @@ import com.nuvio.app.features.watchprogress.resolvedProgressKey import com.nuvio.app.features.watchprogress.toContinueWatchingItem import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL -import com.nuvio.app.features.trakt.WatchProgressSource +import com.nuvio.app.features.tracking.WatchProgressSource import com.nuvio.app.features.watching.domain.WatchingContentRef import kotlinx.serialization.json.Json import kotlin.test.Test diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingSourcesTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingSourcesTest.kt new file mode 100644 index 00000000..5dada923 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingSourcesTest.kt @@ -0,0 +1,45 @@ +package com.nuvio.app.features.tracking + +import com.nuvio.app.features.library.LibrarySourceMode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class TrackingSourcesTest { + @Test + fun `stored legacy source names retain their meaning`() { + assertEquals(WatchProgressSource.TRAKT, WatchProgressSource.fromStorage("TRAKT")) + assertEquals(WatchProgressSource.NUVIO_SYNC, WatchProgressSource.fromStorage("NUVIO_SYNC")) + assertEquals(LibrarySourceMode.TRAKT, librarySourceModeFromStorage("TRAKT")) + } + + @Test + fun `remote watch source falls back when its provider is disconnected`() { + assertEquals( + WatchProgressSource.NUVIO_SYNC, + effectiveWatchProgressSource(WatchProgressSource.SIMKL) { false }, + ) + assertEquals( + WatchProgressSource.SIMKL, + effectiveWatchProgressSource(WatchProgressSource.SIMKL) { it == TrackingProviderId.SIMKL }, + ) + } + + @Test + fun `remote library source falls back to local when disconnected`() { + assertEquals( + LibrarySourceMode.LOCAL, + effectiveLibrarySourceMode(LibrarySourceMode.SIMKL) { false }, + ) + assertEquals( + LibrarySourceMode.SIMKL, + effectiveLibrarySourceMode(LibrarySourceMode.SIMKL) { it == TrackingProviderId.SIMKL }, + ) + } + + @Test + fun `local sources have no remote provider`() { + assertNull(WatchProgressSource.NUVIO_SYNC.providerId) + assertNull(LibrarySourceMode.LOCAL.providerId) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedModelsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedModelsTest.kt index a9664e04..974bf6c9 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedModelsTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedModelsTest.kt @@ -1,7 +1,7 @@ package com.nuvio.app.features.watched import com.nuvio.app.features.trakt.TraktPlatformClock -import com.nuvio.app.features.trakt.WatchProgressSource +import com.nuvio.app.features.tracking.WatchProgressSource import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt index 1857f3d9..cd27d137 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt @@ -2,7 +2,7 @@ package com.nuvio.app.features.watched import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaVideo -import com.nuvio.app.features.trakt.WatchProgressSource +import com.nuvio.app.features.tracking.WatchProgressSource import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -158,9 +158,10 @@ class WatchedRepositoryTest { } @Test - fun watchedItemsForSource_keepsNuvioAndTraktSnapshotsIsolated() { + fun watchedItemsForSource_keepsProviderSnapshotsIsolated() { val nuvioItem = watchedItem(id = "nuvio", markedAtEpochMs = 1_000L) val traktItem = watchedItem(id = "trakt", markedAtEpochMs = 2_000L) + val simklItem = watchedItem(id = "simkl", markedAtEpochMs = 3_000L) assertEquals( listOf(nuvioItem), @@ -168,6 +169,7 @@ class WatchedRepositoryTest { source = WatchProgressSource.NUVIO_SYNC, nuvioItems = listOf(nuvioItem), traktItems = listOf(traktItem), + simklItems = listOf(simklItem), ), ) assertEquals( @@ -176,6 +178,16 @@ class WatchedRepositoryTest { source = WatchProgressSource.TRAKT, nuvioItems = listOf(nuvioItem), traktItems = listOf(traktItem), + simklItems = listOf(simklItem), + ), + ) + assertEquals( + listOf(simklItem), + watchedItemsForSource( + source = WatchProgressSource.SIMKL, + nuvioItems = listOf(nuvioItem), + traktItems = listOf(traktItem), + simklItems = listOf(simklItem), ), ) } @@ -184,6 +196,7 @@ class WatchedRepositoryTest { fun onlyNuvioWatchedStateIsPersisted() { assertTrue(shouldPersistWatchedSource(WatchProgressSource.NUVIO_SYNC)) assertFalse(shouldPersistWatchedSource(WatchProgressSource.TRAKT)) + assertFalse(shouldPersistWatchedSource(WatchProgressSource.SIMKL)) } @Test @@ -193,11 +206,13 @@ class WatchedRepositoryTest { val refreshedTraktItem = watchedItem(id = "new-trakt", markedAtEpochMs = 3_000L) val nuvioItems = mutableMapOf("nuvio" to nuvioItem) val traktItems = mutableMapOf("old-trakt" to previousTraktItem) + val simklItems = mutableMapOf() replaceWatchedItemsForSource( source = WatchProgressSource.TRAKT, nuvioItems = nuvioItems, traktItems = traktItems, + simklItems = simklItems, replacement = mapOf("new-trakt" to refreshedTraktItem), ) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCacheTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCacheTest.kt index 0e0d012f..728382b0 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCacheTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCacheTest.kt @@ -1,6 +1,6 @@ package com.nuvio.app.features.watchprogress -import com.nuvio.app.features.trakt.WatchProgressSource +import com.nuvio.app.features.tracking.WatchProgressSource import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinatorTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinatorTest.kt index 91f6add1..8d8bceeb 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinatorTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinatorTest.kt @@ -1,6 +1,6 @@ package com.nuvio.app.features.watchprogress -import com.nuvio.app.features.trakt.WatchProgressSource +import com.nuvio.app.features.tracking.WatchProgressSource import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse From 8f2f7221c2f59c47341ef80b2982ad9c1b0b21cf Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:06:31 +0530 Subject: [PATCH 02/59] feat(simkl): add secure PKCE authentication --- androidApp/src/main/AndroidManifest.xml | 11 + composeApp/build.gradle.kts | 15 + .../kotlin/com/nuvio/app/MainActivity.kt | 2 + ...PlatformLocalAccountDataCleaner.android.kt | 1 + .../features/simkl/SimklPlatform.android.kt | 127 ++++++ .../trakt/TraktAuthStorage.android.kt | 7 + .../nuvio/app/core/deeplink/AppUrlBridge.kt | 6 +- .../core/storage/LocalAccountDataCleaner.kt | 8 +- .../tracking/TrackingProviderBootstrap.kt | 9 + .../features/profiles/ProfileRepository.kt | 6 +- .../app/features/simkl/SimklApiMetadata.kt | 45 ++ .../app/features/simkl/SimklAuthModels.kt | 52 +++ .../app/features/simkl/SimklAuthParsing.kt | 64 +++ .../app/features/simkl/SimklAuthRepository.kt | 414 ++++++++++++++++++ .../com/nuvio/app/features/simkl/SimklPkce.kt | 51 +++ .../nuvio/app/features/simkl/SimklPlatform.kt | 20 + .../app/features/tracking/TrackingProvider.kt | 25 ++ .../app/features/trakt/TraktAuthBridge.kt | 5 - .../app/features/trakt/TraktAuthRepository.kt | 13 + .../app/features/trakt/TraktAuthStorage.kt | 1 + .../nuvio/app/features/simkl/SimklPkceTest.kt | 79 ++++ .../PlatformLocalAccountDataCleaner.ios.kt | 4 +- .../app/features/simkl/SimklPlatform.ios.kt | 167 +++++++ .../features/trakt/TraktAuthStorage.ios.kt | 4 + 24 files changed, 1124 insertions(+), 12 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklPlatform.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/tracking/TrackingProviderBootstrap.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiMetadata.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthModels.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthParsing.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPkce.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlatform.kt delete mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthBridge.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPkceTest.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/simkl/SimklPlatform.ios.kt diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index c8e68ad9..c996f88e 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -40,6 +40,17 @@ + + + + + + + + 0 && separator < value.lastIndex) { "Invalid encrypted credential" } + val iv = value.substring(0, separator).fromBase64() + val ciphertext = value.substring(separator + 1).fromBase64() + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, getOrCreateSecretKey(), GCMParameterSpec(GCM_TAG_BITS, iv)) + return cipher.doFinal(ciphertext).decodeToString() + } + + private fun getOrCreateSecretKey(): SecretKey { + val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) } + (keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } + return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER).run { + init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build(), + ) + generateKey() + } + } + + private fun ByteArray.toBase64(): String = + Base64.encodeToString(this, Base64.NO_WRAP) + + private fun String.fromBase64(): ByteArray = + Base64.decode(this, Base64.NO_WRAP) +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.android.kt index cc8e8436..ea40727a 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.android.kt @@ -23,4 +23,11 @@ internal actual object TraktAuthStorage { ?.putString(ProfileScopedKey.of(payloadKey), payload) ?.apply() } + + actual fun removeProfile(profileId: Int) { + preferences + ?.edit() + ?.remove(ProfileScopedKey.of(payloadKey, profileId)) + ?.apply() + } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/deeplink/AppUrlBridge.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/deeplink/AppUrlBridge.kt index 4089f5f1..5dde0700 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/deeplink/AppUrlBridge.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/deeplink/AppUrlBridge.kt @@ -1,6 +1,7 @@ package com.nuvio.app.core.deeplink -import com.nuvio.app.features.trakt.handleTraktAuthCallbackUrl +import com.nuvio.app.core.tracking.ensureTrackingProvidersRegistered +import com.nuvio.app.features.tracking.TrackingProviderRegistry import io.ktor.http.Url import io.ktor.http.encodeURLParameter import kotlinx.coroutines.flow.MutableStateFlow @@ -41,7 +42,8 @@ fun handleAppUrl(url: String) { val normalizedUrl = url.trim() if (normalizedUrl.isBlank()) return - handleTraktAuthCallbackUrl(normalizedUrl) + ensureTrackingProvidersRegistered() + TrackingProviderRegistry.handleAuthCallback(normalizedUrl) AppDeepLinkRepository.handleUrl(normalizedUrl) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt index f00ca59a..3fd89740 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt @@ -3,6 +3,7 @@ package com.nuvio.app.core.storage import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.core.sync.SyncManager import com.nuvio.app.core.sync.ProfileSettingsSync +import com.nuvio.app.core.tracking.ensureTrackingProvidersRegistered import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.catalog.CatalogRepository import com.nuvio.app.features.collection.CollectionMobileSettingsRepository @@ -20,13 +21,14 @@ import com.nuvio.app.features.p2p.P2pSettingsRepository import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.player.SubtitleRepository import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.profiles.MAX_PROFILES import com.nuvio.app.features.search.SearchRepository import com.nuvio.app.features.settings.ThemeSettingsRepository import com.nuvio.app.features.streams.StreamContextStore import com.nuvio.app.features.streams.StreamBadgeSettingsRepository import com.nuvio.app.features.streams.StreamLaunchStore import com.nuvio.app.features.streams.StreamsRepository -import com.nuvio.app.features.trakt.TraktAuthRepository +import com.nuvio.app.features.tracking.TrackingProviderRegistry import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.core.ui.CardDepthStyleRepository import com.nuvio.app.core.ui.PosterCardStyleRepository @@ -38,6 +40,8 @@ import com.nuvio.app.features.watched.WatchedRepository internal object LocalAccountDataCleaner { fun wipe() { + ensureTrackingProvidersRegistered() + TrackingProviderRegistry.removeStoredProfiles(1..MAX_PROFILES) SyncManager.cancelAccountSync() WatchProgressSourceCoordinator.clearLocalState() ProfileSettingsSync.clearAccountState() @@ -65,7 +69,7 @@ internal object LocalAccountDataCleaner { ThemeSettingsRepository.clearLocalState() PosterCardStyleRepository.clearLocalState() CardDepthStyleRepository.clearLocalState() - TraktAuthRepository.clearLocalState() + TrackingProviderRegistry.clearLocalState() TraktSettingsRepository.clearLocalState() PlayerSettingsRepository.clearLocalState() StreamBadgeSettingsRepository.clearLocalState() 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 new file mode 100644 index 00000000..a35fddc0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/tracking/TrackingProviderBootstrap.kt @@ -0,0 +1,9 @@ +package com.nuvio.app.core.tracking + +import com.nuvio.app.features.simkl.SimklAuthRepository +import com.nuvio.app.features.trakt.TraktAuthRepository + +fun ensureTrackingProvidersRegistered() { + TraktAuthRepository.descriptor + SimklAuthRepository.descriptor +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index c1c7ca36..62d1b417 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -6,6 +6,7 @@ import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.auth.isAnonymous import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.core.sync.putSyncOriginClientId +import com.nuvio.app.core.tracking.ensureTrackingProvidersRegistered import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.collection.CollectionMobileSettingsRepository import com.nuvio.app.features.collection.CollectionRepository @@ -25,7 +26,7 @@ import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.search.SearchHistoryRepository import com.nuvio.app.features.settings.ThemeSettingsRepository import com.nuvio.app.features.streams.StreamBadgeSettingsRepository -import com.nuvio.app.features.trakt.TraktAuthRepository +import com.nuvio.app.features.tracking.TrackingProviderRegistry import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.watched.WatchedRepository @@ -155,7 +156,8 @@ object ProfileRepository { persist() WatchedRepository.onProfileChanged(profileIndex) TraktSettingsRepository.onProfileChanged() - TraktAuthRepository.onProfileChanged() + ensureTrackingProvidersRegistered() + TrackingProviderRegistry.onProfileChanged() LibraryRepository.onProfileChanged(profileIndex) LibraryDisplaySettingsRepository.onProfileChanged() WatchProgressRepository.onProfileChanged(profileIndex) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiMetadata.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiMetadata.kt new file mode 100644 index 00000000..a76bd9f7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiMetadata.kt @@ -0,0 +1,45 @@ +package com.nuvio.app.features.simkl + +import com.nuvio.app.core.build.AppVersionConfig +import io.ktor.http.encodeURLParameter + +internal const val SIMKL_API_BASE_URL = "https://api.simkl.com" +internal const val SIMKL_AUTHORIZE_URL = "https://simkl.com/oauth/authorize" + +internal val simklAppVersion: String + get() = AppVersionConfig.VERSION_NAME.ifBlank { "dev" } + +internal fun buildSimklApiUrl( + path: String, + query: Map = emptyMap(), +): String { + val normalizedPath = path.trim().let { value -> + if (value.startsWith('/')) value else "/$value" + } + val parameters = linkedMapOf( + "client_id" to SimklConfig.CLIENT_ID, + "app-name" to SimklConfig.APP_NAME, + "app-version" to simklAppVersion, + ).apply { putAll(query) } + return buildString { + append(SIMKL_API_BASE_URL) + append(normalizedPath) + append('?') + append( + parameters.entries.joinToString("&") { (key, value) -> + "${key.encodeURLParameter()}=${value.encodeURLParameter()}" + }, + ) + } +} + +internal fun simklRequestHeaders( + accessToken: String? = null, + contentTypeJson: Boolean = false, +): Map = buildMap { + put("User-Agent", "${SimklConfig.APP_NAME}/$simklAppVersion") + accessToken?.trim()?.takeIf(String::isNotBlank)?.let { token -> + put("Authorization", "Bearer $token") + } + if (contentTypeJson) put("Content-Type", "application/json") +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthModels.kt new file mode 100644 index 00000000..379f6d04 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthModels.kt @@ -0,0 +1,52 @@ +package com.nuvio.app.features.simkl + +import kotlinx.serialization.Serializable + +enum class SimklConnectionMode { + DISCONNECTED, + AWAITING_APPROVAL, + CONNECTED, +} + +enum class SimklAuthError { + MISSING_CLIENT_ID, + INVALID_CALLBACK, + INVALID_CALLBACK_STATE, + AUTHORIZATION_EXPIRED, + TOKEN_EXCHANGE_FAILED, + INVALID_TOKEN_RESPONSE, + AUTHORIZATION_REVOKED, +} + +data class SimklAuthUiState( + val mode: SimklConnectionMode = SimklConnectionMode.DISCONNECTED, + val credentialsConfigured: Boolean = false, + val isLoading: Boolean = false, + val username: String? = null, + val accountId: Long? = null, + val tokenExpiresAtEpochMs: Long? = null, + val pendingAuthorizationStartedAtEpochMs: Long? = null, + val error: SimklAuthError? = null, +) + +@Serializable +internal data class SimklStoredAuthState( + val username: String? = null, + val accountId: Long? = null, + val tokenExpiresAtEpochMs: Long? = null, + val pendingAuthorizationState: String? = null, + val pendingAuthorizationStartedAtEpochMs: Long? = null, +) { + val hasPendingAuthorization: Boolean + get() = !pendingAuthorizationState.isNullOrBlank() +} + +internal sealed interface SimklAuthCallback { + data class AuthorizationCode( + val code: String, + val state: String, + ) : SimklAuthCallback + + data object Invalid : SimklAuthCallback + data object NotSimkl : SimklAuthCallback +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthParsing.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthParsing.kt new file mode 100644 index 00000000..9d2e46bc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthParsing.kt @@ -0,0 +1,64 @@ +package com.nuvio.app.features.simkl + +import io.ktor.http.Url +import io.ktor.http.encodeURLParameter + +internal const val SIMKL_AUTHORIZATION_TIMEOUT_MS = 5L * 60L * 1_000L + +internal fun parseSimklAuthCallback( + callbackUrl: String, + redirectUri: String, +): SimklAuthCallback { + if (callbackUrl != redirectUri && !callbackUrl.startsWith("$redirectUri?")) { + return SimklAuthCallback.NotSimkl + } + val parsed = runCatching { Url(callbackUrl) }.getOrNull() ?: return SimklAuthCallback.Invalid + val code = parsed.parameters["code"].orEmpty().trim() + val state = parsed.parameters["state"].orEmpty().trim() + if (code.isBlank() || state.isBlank()) return SimklAuthCallback.Invalid + return SimklAuthCallback.AuthorizationCode(code = code, state = state) +} + +internal fun isSimklAuthorizationExpired( + startedAtEpochMs: Long?, + nowEpochMs: Long, +): Boolean = startedAtEpochMs == null || + nowEpochMs < startedAtEpochMs || + nowEpochMs - startedAtEpochMs > SIMKL_AUTHORIZATION_TIMEOUT_MS + +internal fun constantTimeEquals(left: String, right: String): Boolean { + val leftBytes = left.encodeToByteArray() + val rightBytes = right.encodeToByteArray() + val length = maxOf(leftBytes.size, rightBytes.size) + var difference = leftBytes.size xor rightBytes.size + for (index in 0 until length) { + val leftByte = leftBytes.getOrElse(index) { 0 }.toInt() + val rightByte = rightBytes.getOrElse(index) { 0 }.toInt() + difference = difference or (leftByte xor rightByte) + } + return difference == 0 +} + +internal fun buildSimklAuthorizationUrl( + clientId: String, + redirectUri: String, + appName: String, + appVersion: String, + material: SimklPkceMaterial, +): String = buildString { + append(SIMKL_AUTHORIZE_URL) + append("?response_type=code") + append("&client_id=") + append(clientId.encodeURLParameter()) + append("&redirect_uri=") + append(redirectUri.encodeURLParameter()) + append("&code_challenge=") + append(material.challenge.encodeURLParameter()) + append("&code_challenge_method=S256") + append("&state=") + append(material.state.encodeURLParameter()) + append("&app-name=") + append(appName.encodeURLParameter()) + append("&app-version=") + append(appVersion.encodeURLParameter()) +} 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 new file mode 100644 index 00000000..ada9d59f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthRepository.kt @@ -0,0 +1,414 @@ +package com.nuvio.app.features.simkl + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.addons.httpRequestRaw +import com.nuvio.app.features.tracking.TrackingAuthProvider +import com.nuvio.app.features.tracking.TrackingCapability +import com.nuvio.app.features.tracking.TrackingProviderDescriptor +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.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +object SimklAuthRepository : TrackingAuthProvider { + private val log = Logger.withTag("SimklAuth") + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + explicitNulls = false + } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val authorizationMutex = Mutex() + + private val _uiState = MutableStateFlow(SimklAuthUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _isAuthenticated = MutableStateFlow(false) + override val isAuthenticated: StateFlow = _isAuthenticated.asStateFlow() + + override val descriptor = TrackingProviderDescriptor( + id = TrackingProviderId.SIMKL, + displayName = "Simkl", + capabilities = setOf( + TrackingCapability.AUTHENTICATION, + TrackingCapability.LIBRARY_READ, + TrackingCapability.LIBRARY_WRITE, + TrackingCapability.WATCHED_READ, + TrackingCapability.WATCHED_WRITE, + TrackingCapability.PROGRESS_READ, + TrackingCapability.PROGRESS_WRITE, + TrackingCapability.SCROBBLE, + ), + ) + + private var hasLoaded = false + private var storedState = SimklStoredAuthState() + private var accessToken: String? = null + + init { + TrackingProviderRegistry.register(this) + } + + override fun ensureLoaded() { + if (hasLoaded) return + loadFromDisk() + } + + override fun onProfileChanged() { + loadFromDisk() + } + + override fun clearLocalState() { + hasLoaded = false + storedState = SimklStoredAuthState() + accessToken = null + publish() + } + + override fun removeStoredProfile(profileId: Int) { + SimklAuthStorage.removeProfile(profileId) + } + + fun snapshot(): SimklAuthUiState { + ensureLoaded() + return uiState.value + } + + fun hasRequiredCredentials(): Boolean = SimklConfig.CLIENT_ID.isNotBlank() + + fun onConnectRequested(): String? { + ensureLoaded() + if (!hasRequiredCredentials()) { + publish(error = SimklAuthError.MISSING_CLIENT_ID) + return null + } + + val material = generateSimklPkceMaterial() + SimklAuthStorage.saveCodeVerifier(material.verifier) + storedState = storedState.copy( + pendingAuthorizationState = material.state, + pendingAuthorizationStartedAtEpochMs = SimklPlatformClock.nowEpochMs(), + ) + persistMetadata() + publish(error = null) + return authorizationUrl(material) + } + + fun pendingAuthorizationUrl(): String? { + ensureLoaded() + val state = storedState.pendingAuthorizationState?.takeIf(String::isNotBlank) ?: return null + val verifier = SimklAuthStorage.loadCodeVerifier()?.takeIf(String::isNotBlank) ?: run { + clearPendingAuthorization() + persistMetadata() + publish(error = SimklAuthError.AUTHORIZATION_EXPIRED) + return null + } + if (isSimklAuthorizationExpired( + startedAtEpochMs = storedState.pendingAuthorizationStartedAtEpochMs, + nowEpochMs = SimklPlatformClock.nowEpochMs(), + ) + ) { + clearPendingAuthorization() + persistMetadata() + publish(error = SimklAuthError.AUTHORIZATION_EXPIRED) + return null + } + return authorizationUrl( + SimklPkceMaterial( + verifier = verifier, + challenge = SimklPkceCrypto.sha256(verifier.encodeToByteArray()).base64UrlWithoutPadding(), + state = state, + ), + ) + } + + fun onCancelAuthorization() { + ensureLoaded() + clearPendingAuthorization() + persistMetadata() + publish(error = null) + } + + override fun handleAuthCallback(url: String): Boolean { + ensureLoaded() + return when (val callback = parseSimklAuthCallback(url, SimklConfig.REDIRECT_URI)) { + SimklAuthCallback.NotSimkl -> false + SimklAuthCallback.Invalid -> { + clearPendingAuthorization() + persistMetadata() + publish(error = SimklAuthError.INVALID_CALLBACK) + true + } + is SimklAuthCallback.AuthorizationCode -> { + scope.launch { completeAuthorization(callback) } + true + } + } + } + + fun onDisconnectRequested() { + ensureLoaded() + accessToken = null + SimklAuthStorage.saveAccessToken(null) + clearPendingAuthorization() + storedState = SimklStoredAuthState() + persistMetadata() + publish(error = null) + } + + internal fun authorizedAccessToken(): String? { + ensureLoaded() + val token = accessToken?.takeIf(String::isNotBlank) ?: return null + val expiresAt = storedState.tokenExpiresAtEpochMs + if (expiresAt != null && SimklPlatformClock.nowEpochMs() >= expiresAt - TOKEN_EXPIRY_SKEW_MS) { + invalidateCredentials(SimklAuthError.AUTHORIZATION_EXPIRED) + return null + } + return token + } + + internal fun onUnauthorizedResponse() { + invalidateCredentials(SimklAuthError.AUTHORIZATION_REVOKED) + } + + suspend fun refreshUserSettings(): String? { + val token = authorizedAccessToken() ?: return null + return fetchAndStoreUserSettings(token) + } + + private suspend fun completeAuthorization(callback: SimklAuthCallback.AuthorizationCode) = + authorizationMutex.withLock { + publish(isLoading = true, error = null) + val expectedState = storedState.pendingAuthorizationState + val verifier = SimklAuthStorage.loadCodeVerifier() + val isExpired = isSimklAuthorizationExpired( + startedAtEpochMs = storedState.pendingAuthorizationStartedAtEpochMs, + nowEpochMs = SimklPlatformClock.nowEpochMs(), + ) + if (expectedState.isNullOrBlank() || verifier.isNullOrBlank() || isExpired) { + clearPendingAuthorization() + persistMetadata() + publish(isLoading = false, error = SimklAuthError.AUTHORIZATION_EXPIRED) + return@withLock + } + if (!constantTimeEquals(callback.state, expectedState)) { + clearPendingAuthorization() + persistMetadata() + publish(isLoading = false, error = SimklAuthError.INVALID_CALLBACK_STATE) + return@withLock + } + + val request = SimklTokenRequest( + code = callback.code, + clientId = SimklConfig.CLIENT_ID, + codeVerifier = verifier, + redirectUri = SimklConfig.REDIRECT_URI, + ) + val response = try { + httpRequestRaw( + method = "POST", + url = "$SIMKL_API_BASE_URL/oauth/token", + headers = simklRequestHeaders(contentTypeJson = true), + body = json.encodeToString(request), + ) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.w { "Simkl token exchange failed: ${error.message}" } + clearPendingAuthorization() + persistMetadata() + publish(isLoading = false, error = SimklAuthError.TOKEN_EXCHANGE_FAILED) + return@withLock + } + + if (response.status !in 200..299) { + clearPendingAuthorization() + persistMetadata() + publish(isLoading = false, error = SimklAuthError.TOKEN_EXCHANGE_FAILED) + return@withLock + } + val token = runCatching { json.decodeFromString(response.body) } + .getOrNull() + ?.takeIf { it.accessToken.isNotBlank() } + if (token == null) { + clearPendingAuthorization() + persistMetadata() + publish(isLoading = false, error = SimklAuthError.INVALID_TOKEN_RESPONSE) + return@withLock + } + + accessToken = token.accessToken + SimklAuthStorage.saveAccessToken(token.accessToken) + clearPendingAuthorization() + storedState = storedState.copy( + tokenExpiresAtEpochMs = token.expiresIn + ?.takeIf { seconds -> seconds > 0L } + ?.let { seconds -> SimklPlatformClock.nowEpochMs() + seconds * 1_000L }, + ) + persistMetadata() + publish(isLoading = false, error = null) + fetchAndStoreUserSettings(token.accessToken) + } + + private suspend fun fetchAndStoreUserSettings(token: String): String? { + val response = try { + httpRequestRaw( + method = "POST", + url = buildSimklApiUrl("/users/settings"), + headers = simklRequestHeaders(accessToken = token, contentTypeJson = true), + body = "{}", + ) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.w { "Failed to fetch Simkl user settings: ${error.message}" } + return null + } + if (response.status == 401) { + onUnauthorizedResponse() + return null + } + if (response.status !in 200..299) return null + val settings = runCatching { json.decodeFromString(response.body) } + .getOrNull() ?: return null + storedState = storedState.copy( + username = settings.user?.name, + accountId = settings.account?.id, + ) + persistMetadata() + publish(error = null) + return storedState.username + } + + private fun loadFromDisk() { + hasLoaded = true + storedState = SimklAuthStorage.loadMetadataPayload() + ?.trim() + ?.takeIf(String::isNotEmpty) + ?.let { payload -> + runCatching { json.decodeFromString(payload) } + .onFailure { error -> log.w { "Failed to parse Simkl auth metadata: ${error.message}" } } + .getOrNull() + } + ?: SimklStoredAuthState() + accessToken = SimklAuthStorage.loadAccessToken()?.takeIf(String::isNotBlank) + if (accessToken != null && storedState.tokenExpiresAtEpochMs?.let { expiresAt -> + SimklPlatformClock.nowEpochMs() >= expiresAt - TOKEN_EXPIRY_SKEW_MS + } == true + ) { + accessToken = null + SimklAuthStorage.saveAccessToken(null) + storedState = SimklStoredAuthState() + persistMetadata() + } + if (storedState.hasPendingAuthorization && isSimklAuthorizationExpired( + startedAtEpochMs = storedState.pendingAuthorizationStartedAtEpochMs, + nowEpochMs = SimklPlatformClock.nowEpochMs(), + ) + ) { + clearPendingAuthorization() + persistMetadata() + } + publish(error = null) + } + + private fun invalidateCredentials(error: SimklAuthError) { + accessToken = null + SimklAuthStorage.saveAccessToken(null) + clearPendingAuthorization() + storedState = SimklStoredAuthState() + persistMetadata() + publish(isLoading = false, error = error) + } + + private fun clearPendingAuthorization() { + SimklAuthStorage.saveCodeVerifier(null) + storedState = storedState.copy( + pendingAuthorizationState = null, + pendingAuthorizationStartedAtEpochMs = null, + ) + } + + private fun persistMetadata() { + SimklAuthStorage.saveMetadataPayload(json.encodeToString(storedState)) + } + + private fun publish( + isLoading: Boolean = _uiState.value.isLoading, + error: SimklAuthError? = _uiState.value.error, + ) { + val authenticated = !accessToken.isNullOrBlank() + _isAuthenticated.value = authenticated + _uiState.value = SimklAuthUiState( + mode = when { + authenticated -> SimklConnectionMode.CONNECTED + storedState.hasPendingAuthorization -> SimklConnectionMode.AWAITING_APPROVAL + else -> SimklConnectionMode.DISCONNECTED + }, + credentialsConfigured = hasRequiredCredentials(), + isLoading = isLoading, + username = storedState.username, + accountId = storedState.accountId, + tokenExpiresAtEpochMs = storedState.tokenExpiresAtEpochMs, + pendingAuthorizationStartedAtEpochMs = storedState.pendingAuthorizationStartedAtEpochMs, + error = error, + ) + } + + private fun authorizationUrl(material: SimklPkceMaterial): String = + buildSimklAuthorizationUrl( + clientId = SimklConfig.CLIENT_ID, + redirectUri = SimklConfig.REDIRECT_URI, + appName = SimklConfig.APP_NAME, + appVersion = simklAppVersion, + material = material, + ) + + private const val TOKEN_EXPIRY_SKEW_MS = 60_000L +} + +@Serializable +private data class SimklTokenRequest( + val code: String, + @SerialName("client_id") val clientId: String, + @SerialName("code_verifier") val codeVerifier: String, + @SerialName("redirect_uri") val redirectUri: String, + @SerialName("grant_type") val grantType: String = "authorization_code", +) + +@Serializable +private data class SimklTokenResponse( + @SerialName("access_token") val accessToken: String, + @SerialName("token_type") val tokenType: String? = null, + val scope: String? = null, + @SerialName("expires_in") val expiresIn: Long? = null, +) + +@Serializable +private data class SimklUserSettingsResponse( + val user: SimklUser? = null, + val account: SimklAccount? = null, +) + +@Serializable +private data class SimklUser( + val name: String? = null, +) + +@Serializable +private data class SimklAccount( + val id: Long? = null, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPkce.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPkce.kt new file mode 100644 index 00000000..4dc0bf00 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPkce.kt @@ -0,0 +1,51 @@ +package com.nuvio.app.features.simkl + +internal data class SimklPkceMaterial( + val verifier: String, + val challenge: String, + val state: String, +) + +internal fun generateSimklPkceMaterial(): SimklPkceMaterial = + createSimklPkceMaterial( + verifierEntropy = SimklPkceCrypto.secureRandomBytes(32), + stateEntropy = SimklPkceCrypto.secureRandomBytes(32), + ) + +internal fun createSimklPkceMaterial( + verifierEntropy: ByteArray, + stateEntropy: ByteArray, +): SimklPkceMaterial { + require(verifierEntropy.size >= 32) { "PKCE verifier entropy must be at least 32 bytes" } + require(stateEntropy.size >= 16) { "OAuth state entropy must be at least 16 bytes" } + val verifier = verifierEntropy.base64UrlWithoutPadding() + require(verifier.length in 43..128) { "PKCE verifier must be 43-128 characters" } + return SimklPkceMaterial( + verifier = verifier, + challenge = SimklPkceCrypto.sha256(verifier.encodeToByteArray()).base64UrlWithoutPadding(), + state = stateEntropy.base64UrlWithoutPadding(), + ) +} + +internal fun ByteArray.base64UrlWithoutPadding(): String { + if (isEmpty()) return "" + val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + return buildString((size * 4 + 2) / 3) { + var index = 0 + while (index < size) { + val first = this@base64UrlWithoutPadding[index].toInt() and 0xff + val second = this@base64UrlWithoutPadding.getOrNull(index + 1)?.toInt()?.and(0xff) + val third = this@base64UrlWithoutPadding.getOrNull(index + 2)?.toInt()?.and(0xff) + + append(alphabet[first ushr 2]) + append(alphabet[((first and 0x03) shl 4) or ((second ?: 0) ushr 4)]) + if (second != null) { + append(alphabet[((second and 0x0f) shl 2) or ((third ?: 0) ushr 6)]) + } + if (third != null) { + append(alphabet[third and 0x3f]) + } + index += 3 + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlatform.kt new file mode 100644 index 00000000..18295df6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlatform.kt @@ -0,0 +1,20 @@ +package com.nuvio.app.features.simkl + +internal expect object SimklPlatformClock { + fun nowEpochMs(): Long +} + +internal expect object SimklPkceCrypto { + fun secureRandomBytes(size: Int): ByteArray + fun sha256(value: ByteArray): ByteArray +} + +internal expect object SimklAuthStorage { + fun loadMetadataPayload(): String? + fun saveMetadataPayload(payload: String) + fun loadAccessToken(): String? + fun saveAccessToken(value: String?) + fun loadCodeVerifier(): String? + fun saveCodeVerifier(value: 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 49916f09..c12bf602 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 @@ -45,6 +45,8 @@ interface TrackingAuthProvider { fun ensureLoaded() fun onProfileChanged() fun clearLocalState() + fun removeStoredProfile(profileId: Int) + fun handleAuthCallback(url: String): Boolean = false } object TrackingProviderRegistry { @@ -73,6 +75,29 @@ object TrackingProviderRegistry { .filter { provider -> capability in provider.descriptor.capabilities } .sortedBy { provider -> provider.descriptor.id.ordinal } + fun handleAuthCallback(url: String): Boolean = + providersWith(TrackingCapability.AUTHENTICATION) + .any { provider -> provider.handleAuthCallback(url) } + + fun ensureLoaded() { + providerSnapshot().forEach(TrackingAuthProvider::ensureLoaded) + } + + fun onProfileChanged() { + providerSnapshot().forEach(TrackingAuthProvider::onProfileChanged) + } + + fun clearLocalState() { + providerSnapshot().forEach(TrackingAuthProvider::clearLocalState) + } + + fun removeStoredProfiles(profileIds: Iterable) { + val providers = providerSnapshot() + profileIds.forEach { profileId -> + providers.forEach { provider -> provider.removeStoredProfile(profileId) } + } + } + private fun providerSnapshot(): List = synchronized(lock) { authProviders.values.toList() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthBridge.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthBridge.kt deleted file mode 100644 index 57e7c202..00000000 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthBridge.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.nuvio.app.features.trakt - -fun handleTraktAuthCallbackUrl(url: String) { - TraktAuthRepository.onAuthCallbackReceived(url) -} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt index e2c48d8d..c1f503d2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt @@ -91,6 +91,10 @@ object TraktAuthRepository : TrackingAuthProvider { publish() } + override fun removeStoredProfile(profileId: Int) { + TraktAuthStorage.removeProfile(profileId) + } + fun snapshot(): TraktAuthUiState { ensureLoaded() return _uiState.value @@ -154,6 +158,15 @@ object TraktAuthRepository : TrackingAuthProvider { } } + override fun handleAuthCallback(url: String): Boolean { + if (!isTraktAuthCallback(url)) return false + onAuthCallbackReceived(url) + return true + } + + private fun isTraktAuthCallback(url: String): Boolean = + url == TraktConfig.REDIRECT_URI || url.startsWith("${TraktConfig.REDIRECT_URI}?") + suspend fun authorizedHeaders(): Map? { ensureLoaded() if (!authState.isAuthenticated) return null diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.kt index 3527be20..5337d208 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.kt @@ -3,4 +3,5 @@ package com.nuvio.app.features.trakt internal expect object TraktAuthStorage { fun loadPayload(): String? fun savePayload(payload: String) + fun removeProfile(profileId: Int) } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPkceTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPkceTest.kt new file mode 100644 index 00000000..cba7e81c --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPkceTest.kt @@ -0,0 +1,79 @@ +package com.nuvio.app.features.simkl + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class SimklPkceTest { + @Test + fun `base64 url encoding is unpadded and url safe`() { + assertEquals("", byteArrayOf().base64UrlWithoutPadding()) + assertEquals("Zg", "f".encodeToByteArray().base64UrlWithoutPadding()) + assertEquals("Zm8", "fo".encodeToByteArray().base64UrlWithoutPadding()) + assertEquals("Zm9v", "foo".encodeToByteArray().base64UrlWithoutPadding()) + assertEquals("-_8", byteArrayOf(0xfb.toByte(), 0xff.toByte()).base64UrlWithoutPadding()) + } + + @Test + fun `pkce material uses compliant verifier and S256 challenge`() { + val material = createSimklPkceMaterial( + verifierEntropy = ByteArray(32) { it.toByte() }, + stateEntropy = ByteArray(32) { (it + 32).toByte() }, + ) + + assertEquals(43, material.verifier.length) + assertEquals("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8", material.verifier) + assertEquals("6oZqdX5MOLq_qBJ8vppAnT4fk6AP8UiP9zX8-Rev_9A", material.challenge) + assertTrue(material.verifier.all { it.isLetterOrDigit() || it in "-._~" }) + assertFalse('=' in material.verifier) + assertEquals(43, material.challenge.length) + assertTrue(material.state.length >= 22) + } + + @Test + fun `oauth state comparison requires an exact value`() { + assertTrue(constantTimeEquals("same-state", "same-state")) + assertFalse(constantTimeEquals("same-state", "different-state")) + assertFalse(constantTimeEquals("same-state", "same-state-extra")) + } + + @Test + fun `authorization URL uses browser host and exact S256 method`() { + val url = buildSimklAuthorizationUrl( + clientId = "client id", + redirectUri = "nuvio://auth/simkl", + appName = "nuvio", + appVersion = "1.2.3", + material = SimklPkceMaterial("verifier", "challenge", "state"), + ) + + assertTrue(url.startsWith("https://simkl.com/oauth/authorize?")) + assertTrue("client_id=client+id" in url || "client_id=client%20id" in url) + assertTrue("code_challenge_method=S256" in url) + assertTrue("redirect_uri=nuvio%3A%2F%2Fauth%2Fsimkl" in url) + } + + @Test + fun `callback parser rejects other routes and missing state`() { + assertIs( + parseSimklAuthCallback("nuvio://auth/trakt?code=a&state=b", "nuvio://auth/simkl"), + ) + assertIs( + parseSimklAuthCallback("nuvio://auth/simkl?code=a", "nuvio://auth/simkl"), + ) + assertEquals( + SimklAuthCallback.AuthorizationCode(code = "a", state = "b"), + parseSimklAuthCallback("nuvio://auth/simkl?code=a&state=b", "nuvio://auth/simkl"), + ) + } + + @Test + fun `pending authorization expires after five minutes`() { + assertFalse(isSimklAuthorizationExpired(1_000L, 301_000L)) + assertTrue(isSimklAuthorizationExpired(1_000L, 301_001L)) + assertTrue(isSimklAuthorizationExpired(null, 1_000L)) + assertTrue(isSimklAuthorizationExpired(2_000L, 1_000L)) + } +} 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 af708049..536492d8 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 @@ -1,6 +1,7 @@ package com.nuvio.app.core.storage import platform.Foundation.NSUserDefaults +import com.nuvio.app.features.profiles.MAX_PROFILES internal actual object PlatformLocalAccountDataCleaner { private val plainKeys = listOf( @@ -52,6 +53,7 @@ internal actual object PlatformLocalAccountDataCleaner { "mdblist_use_letterboxd", "mdblist_use_audience", "trakt_auth_payload", + "simkl_auth_metadata", "trakt_library_payload", "trakt_settings_payload", "library_display_settings_payload", @@ -65,7 +67,7 @@ internal actual object PlatformLocalAccountDataCleaner { plainKeys.forEach(defaults::removeObjectForKey) - (1..4).forEach { profileId -> + (1..MAX_PROFILES).forEach { profileId -> profileIndexedPrefixes.forEach { prefix -> defaults.removeObjectForKey("$prefix$profileId") } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/simkl/SimklPlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/simkl/SimklPlatform.ios.kt new file mode 100644 index 00000000..76c4b781 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/simkl/SimklPlatform.ios.kt @@ -0,0 +1,167 @@ +package com.nuvio.app.features.simkl + +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.features.plugins.cryptointerop.CC_SHA256 +import com.nuvio.app.features.plugins.cryptointerop.CC_SHA256_DIGEST_LENGTH +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.alloc +import kotlinx.cinterop.get +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.refTo +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.value +import platform.CoreFoundation.CFDataCreate +import platform.CoreFoundation.CFDataGetBytePtr +import platform.CoreFoundation.CFDataGetLength +import platform.CoreFoundation.CFDataRef +import platform.CoreFoundation.CFDictionaryCreateMutable +import platform.CoreFoundation.CFDictionarySetValue +import platform.CoreFoundation.CFMutableDictionaryRef +import platform.CoreFoundation.CFRelease +import platform.CoreFoundation.CFStringCreateWithCString +import platform.CoreFoundation.CFTypeRefVar +import platform.CoreFoundation.kCFBooleanTrue +import platform.CoreFoundation.kCFStringEncodingUTF8 +import platform.Foundation.NSDate +import platform.Foundation.NSUserDefaults +import platform.Foundation.timeIntervalSince1970 +import platform.Security.SecItemAdd +import platform.Security.SecItemCopyMatching +import platform.Security.SecItemDelete +import platform.Security.SecRandomCopyBytes +import platform.Security.errSecItemNotFound +import platform.Security.errSecSuccess +import platform.Security.kSecAttrAccount +import platform.Security.kSecAttrService +import platform.Security.kSecClass +import platform.Security.kSecClassGenericPassword +import platform.Security.kSecMatchLimit +import platform.Security.kSecMatchLimitOne +import platform.Security.kSecRandomDefault +import platform.Security.kSecReturnData +import platform.Security.kSecValueData + +internal actual object SimklPlatformClock { + actual fun nowEpochMs(): Long = (NSDate().timeIntervalSince1970 * 1_000.0).toLong() +} + +internal actual object SimklPkceCrypto { + @OptIn(ExperimentalForeignApi::class) + actual fun secureRandomBytes(size: Int): ByteArray { + require(size > 0) + val bytes = ByteArray(size) + val status = SecRandomCopyBytes(kSecRandomDefault, size.toULong(), bytes.refTo(0)) + check(status == errSecSuccess) { "Secure random generation failed" } + return bytes + } + + @OptIn(ExperimentalForeignApi::class) + actual fun sha256(value: ByteArray): ByteArray { + require(value.isNotEmpty()) + val output = UByteArray(CC_SHA256_DIGEST_LENGTH.toInt()) + CC_SHA256(value.refTo(0), value.size.toUInt(), output.refTo(0)) + return ByteArray(output.size) { index -> output[index].toByte() } + } +} + +internal actual object SimklAuthStorage { + private const val METADATA_KEY = "simkl_auth_metadata" + private const val ACCESS_TOKEN_KEY = "simkl_access_token" + private const val CODE_VERIFIER_KEY = "simkl_code_verifier" + private const val KEYCHAIN_SERVICE = "com.nuvio.media.simkl" + + actual fun loadMetadataPayload(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(METADATA_KEY)) + + actual fun saveMetadataPayload(payload: String) { + NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(METADATA_KEY)) + } + + actual fun loadAccessToken(): String? = loadKeychainValue(ACCESS_TOKEN_KEY) + + actual fun saveAccessToken(value: String?) = saveKeychainValue(ACCESS_TOKEN_KEY, value) + + actual fun loadCodeVerifier(): String? = loadKeychainValue(CODE_VERIFIER_KEY) + + actual fun saveCodeVerifier(value: String?) = saveKeychainValue(CODE_VERIFIER_KEY, value) + + actual fun removeProfile(profileId: Int) { + NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(METADATA_KEY, profileId)) + deleteKeychainValue(ACCESS_TOKEN_KEY, profileId) + deleteKeychainValue(CODE_VERIFIER_KEY, profileId) + } + + @OptIn(ExperimentalForeignApi::class) + private fun loadKeychainValue(key: String): String? = withKeychainQuery(key) { query -> + CFDictionarySetValue(query, kSecReturnData, kCFBooleanTrue) + CFDictionarySetValue(query, kSecMatchLimit, kSecMatchLimitOne) + memScoped { + val result = alloc() + val status = SecItemCopyMatching(query, result.ptr) + if (status == errSecItemNotFound) return@memScoped null + if (status != errSecSuccess) return@memScoped null + val data: CFDataRef = result.value?.reinterpret() ?: return@memScoped null + try { + val length = CFDataGetLength(data).toInt() + val bytes = CFDataGetBytePtr(data) ?: return@memScoped null + ByteArray(length) { index -> bytes[index].toByte() }.decodeToString() + } finally { + CFRelease(data) + } + } + } + + @OptIn(ExperimentalForeignApi::class) + private fun saveKeychainValue(key: String, value: String?) { + deleteKeychainValue(key) + if (value.isNullOrBlank()) return + withKeychainQuery(key) { query -> + val bytes = value.encodeToByteArray().toUByteArray() + val data = CFDataCreate(null, bytes.refTo(0), bytes.size.toLong()) + ?: error("Unable to encode Simkl credential") + try { + CFDictionarySetValue(query, kSecValueData, data) + check(SecItemAdd(query, null) == errSecSuccess) { "Unable to store Simkl credential" } + } finally { + CFRelease(data) + } + } + } + + @OptIn(ExperimentalForeignApi::class) + private fun deleteKeychainValue(key: String, profileId: Int? = null) { + withKeychainQuery(key, profileId) { query -> SecItemDelete(query) } + } + + @OptIn(ExperimentalForeignApi::class) + private inline fun withKeychainQuery( + key: String, + profileId: Int? = null, + block: (CFMutableDictionaryRef) -> T, + ): T { + val service = CFStringCreateWithCString(null, KEYCHAIN_SERVICE, kCFStringEncodingUTF8) + ?: error("Unable to encode Keychain service") + val account = CFStringCreateWithCString( + null, + profileId?.let { id -> ProfileScopedKey.of(key, id) } ?: ProfileScopedKey.of(key), + kCFStringEncodingUTF8, + ) ?: error("Unable to encode Keychain account") + val query = CFDictionaryCreateMutable( + allocator = null, + capacity = 0L, + keyCallBacks = null, + valueCallBacks = null, + ) ?: error("Unable to create Keychain query") + try { + CFDictionarySetValue(query, kSecClass, kSecClassGenericPassword) + CFDictionarySetValue(query, kSecAttrService, service) + CFDictionarySetValue(query, kSecAttrAccount, account) + return block(query) + } finally { + CFRelease(query) + CFRelease(account) + CFRelease(service) + } + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.ios.kt index 3ccbaff4..55b9a6e3 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.ios.kt @@ -12,4 +12,8 @@ internal actual object TraktAuthStorage { actual fun savePayload(payload: String) { NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey)) } + + actual fun removeProfile(profileId: Int) { + NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(payloadKey, profileId)) + } } From f7c17e656e300ac799dbd424f50b53d085263761 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:13:46 +0530 Subject: [PATCH 03/59] feat(simkl): centralize API policy --- .../app/features/simkl/SimklApiClient.kt | 245 ++++++++++++++++++ .../app/features/simkl/SimklApiMetadata.kt | 12 +- .../app/features/simkl/SimklAuthRepository.kt | 24 +- .../app/features/simkl/SimklApiClientTest.kt | 196 ++++++++++++++ 4 files changed, 458 insertions(+), 19 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt new file mode 100644 index 00000000..47ed55aa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt @@ -0,0 +1,245 @@ +package com.nuvio.app.features.simkl + +import com.nuvio.app.features.addons.RawHttpResponse +import com.nuvio.app.features.addons.httpRequestRaw +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlin.math.max +import kotlin.random.Random + +private const val SIMKL_MAX_RESPONSE_BODY_BYTES = 8 * 1024 * 1024 + +internal enum class SimklHttpMethod { + GET, + POST, + DELETE, +} + +internal data class SimklApiRequest( + val method: SimklHttpMethod, + val path: String, + val query: Map = emptyMap(), + val body: String = "", + val requiresAuthentication: Boolean = true, + val scrobbleStopConflictIsSuccess: Boolean = false, +) + +internal data class SimklApiResponse( + val status: Int, + val body: String, + val headers: Map, + val isSoftSuccess: Boolean = false, +) + +internal class SimklApiException( + val status: Int?, + val errorCode: String?, + override val message: String, + cause: Throwable? = null, +) : Exception(message, cause) + +internal fun interface SimklHttpEngine { + suspend fun execute( + method: String, + url: String, + headers: Map, + body: String, + ): RawHttpResponse +} + +internal class SimklApiClient( + private val engine: SimklHttpEngine, + private val accessToken: () -> String?, + private val onUnauthorized: () -> Unit, + private val nowEpochMs: () -> Long = SimklPlatformClock::nowEpochMs, + private val sleep: suspend (Long) -> Unit = { delayMs -> delay(delayMs) }, + private val retryJitterMs: () -> Long = { Random.nextLong(RETRY_JITTER_BOUND_MS + 1L) }, +) { + private val requestMutex = Mutex() + private val json = Json { ignoreUnknownKeys = true } + private var nextGetAtEpochMs = 0L + private var nextPostAtEpochMs = 0L + + suspend fun execute(request: SimklApiRequest): SimklApiResponse = requestMutex.withLock { + val token = if (request.requiresAuthentication) { + accessToken()?.takeIf(String::isNotBlank) + ?: throw SimklApiException( + status = 401, + errorCode = "authentication_required", + message = "Simkl authentication is required", + ) + } else { + null + } + + var lastTransportFailure: Throwable? = null + for (attempt in 0..MAX_RETRIES) { + awaitRateLimit(request.method) + val response = try { + engine.execute( + method = request.method.name, + url = buildSimklApiUrl(request.path, request.query), + headers = simklRequestHeaders( + accessToken = token, + contentTypeJson = request.body.isNotEmpty(), + ), + body = request.body, + ) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + lastTransportFailure = error + if (attempt == MAX_RETRIES) { + throw SimklApiException( + status = null, + errorCode = "transport_failure", + message = "Simkl request failed", + cause = error, + ) + } + sleep(retryDelayMs(attempt, retryAfterHeader = null, retryJitterMs())) + continue + } + + when (classifySimklResponse(response.status, request.scrobbleStopConflictIsSuccess)) { + SimklResponseAction.SUCCESS -> return@withLock response.toApiResponse() + SimklResponseAction.SOFT_SUCCESS -> { + return@withLock response.toApiResponse(isSoftSuccess = true) + } + SimklResponseAction.REAUTHENTICATE -> { + onUnauthorized() + throw response.toApiException(json) + } + SimklResponseAction.FAIL -> throw response.toApiException(json) + SimklResponseAction.RETRY -> { + if (attempt == MAX_RETRIES) throw response.toApiException(json) + sleep( + retryDelayMs( + attempt = attempt, + retryAfterHeader = response.headers.headerValue("retry-after"), + jitterMs = retryJitterMs(), + ), + ) + } + } + } + + throw SimklApiException( + status = null, + errorCode = "transport_failure", + message = "Simkl request failed", + cause = lastTransportFailure, + ) + } + + private suspend fun awaitRateLimit(method: SimklHttpMethod) { + val now = nowEpochMs() + val scheduledAt = when (method) { + SimklHttpMethod.GET -> max(now, nextGetAtEpochMs) + SimklHttpMethod.POST, SimklHttpMethod.DELETE -> max(now, nextPostAtEpochMs) + } + if (scheduledAt > now) sleep(scheduledAt - now) + val requestAt = max(scheduledAt, nowEpochMs()) + when (method) { + SimklHttpMethod.GET -> nextGetAtEpochMs = requestAt + GET_INTERVAL_MS + SimklHttpMethod.POST, SimklHttpMethod.DELETE -> nextPostAtEpochMs = requestAt + POST_INTERVAL_MS + } + } + + private companion object { + const val GET_INTERVAL_MS = 100L + const val POST_INTERVAL_MS = 1_000L + const val MAX_RETRIES = 5 + const val RETRY_JITTER_BOUND_MS = 1_000L + } +} + +internal object SimklApi { + val client: SimklApiClient by lazy { + SimklApiClient( + engine = SimklHttpEngine { method, url, headers, body -> + httpRequestRaw( + method = method, + url = url, + headers = headers, + body = body, + maxResponseBodyBytes = SIMKL_MAX_RESPONSE_BODY_BYTES, + ) + }, + accessToken = SimklAuthRepository::authorizedAccessToken, + onUnauthorized = SimklAuthRepository::onUnauthorizedResponse, + ) + } +} + +internal enum class SimklResponseAction { + SUCCESS, + SOFT_SUCCESS, + REAUTHENTICATE, + RETRY, + FAIL, +} + +internal fun classifySimklResponse( + status: Int, + scrobbleStopConflictIsSuccess: Boolean = false, +): SimklResponseAction = when { + status in 200..299 -> SimklResponseAction.SUCCESS + status == 409 && scrobbleStopConflictIsSuccess -> SimklResponseAction.SOFT_SUCCESS + status == 401 -> SimklResponseAction.REAUTHENTICATE + status == 429 || status == 500 || status == 502 || status == 503 -> SimklResponseAction.RETRY + else -> SimklResponseAction.FAIL +} + +internal fun retryDelayMs( + attempt: Int, + retryAfterHeader: String?, + jitterMs: Long, +): Long { + require(attempt in 0..4) { "Retry attempt must be between 0 and 4" } + val exponentialDelayMs = 1_000L shl attempt + val retryAfterMs = retryAfterHeader + ?.substringBefore(',') + ?.trim() + ?.toLongOrNull() + ?.coerceAtLeast(0L) + ?.times(1_000L) + ?: 0L + return max(exponentialDelayMs, retryAfterMs) + jitterMs.coerceIn(0L, 1_000L) +} + +private fun RawHttpResponse.toApiResponse(isSoftSuccess: Boolean = false): SimklApiResponse = + SimklApiResponse( + status = status, + body = body, + headers = headers, + isSoftSuccess = isSoftSuccess, + ) + +private fun RawHttpResponse.toApiException(json: Json): SimklApiException { + val envelope = body.takeIf(String::isNotBlank)?.let { payload -> + runCatching { json.decodeFromString(payload) }.getOrNull() + } + return SimklApiException( + status = status, + errorCode = envelope?.error, + message = envelope?.message?.takeIf(String::isNotBlank) + ?: envelope?.error?.takeIf(String::isNotBlank) + ?: "Simkl request failed with HTTP $status", + ) +} + +private fun Map.headerValue(name: String): String? = + entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) }?.value + +@Serializable +private data class SimklErrorEnvelope( + val error: String? = null, + val code: Int? = null, + val message: String? = null, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiMetadata.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiMetadata.kt index a76bd9f7..99d1be26 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiMetadata.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiMetadata.kt @@ -16,11 +16,12 @@ internal fun buildSimklApiUrl( val normalizedPath = path.trim().let { value -> if (value.startsWith('/')) value else "/$value" } - val parameters = linkedMapOf( - "client_id" to SimklConfig.CLIENT_ID, - "app-name" to SimklConfig.APP_NAME, - "app-version" to simklAppVersion, - ).apply { putAll(query) } + val parameters = linkedMapOf().apply { + putAll(query) + put("client_id", SimklConfig.CLIENT_ID) + put("app-name", SimklConfig.APP_NAME) + put("app-version", simklAppVersion) + } return buildString { append(SIMKL_API_BASE_URL) append(normalizedPath) @@ -38,6 +39,7 @@ internal fun simklRequestHeaders( contentTypeJson: Boolean = false, ): Map = buildMap { put("User-Agent", "${SimklConfig.APP_NAME}/$simklAppVersion") + put("Accept", "application/json") accessToken?.trim()?.takeIf(String::isNotBlank)?.let { token -> put("Authorization", "Bearer $token") } 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 ada9d59f..337d6968 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 @@ -185,8 +185,8 @@ object SimklAuthRepository : TrackingAuthProvider { } suspend fun refreshUserSettings(): String? { - val token = authorizedAccessToken() ?: return null - return fetchAndStoreUserSettings(token) + authorizedAccessToken() ?: return null + return fetchAndStoreUserSettings() } private suspend fun completeAuthorization(callback: SimklAuthCallback.AuthorizationCode) = @@ -260,16 +260,17 @@ object SimklAuthRepository : TrackingAuthProvider { ) persistMetadata() publish(isLoading = false, error = null) - fetchAndStoreUserSettings(token.accessToken) + fetchAndStoreUserSettings() } - private suspend fun fetchAndStoreUserSettings(token: String): String? { + private suspend fun fetchAndStoreUserSettings(): String? { val response = try { - httpRequestRaw( - method = "POST", - url = buildSimklApiUrl("/users/settings"), - headers = simklRequestHeaders(accessToken = token, contentTypeJson = true), - body = "{}", + SimklApi.client.execute( + SimklApiRequest( + method = SimklHttpMethod.POST, + path = "/users/settings", + body = "{}", + ), ) } catch (error: CancellationException) { throw error @@ -277,11 +278,6 @@ object SimklAuthRepository : TrackingAuthProvider { log.w { "Failed to fetch Simkl user settings: ${error.message}" } return null } - if (response.status == 401) { - onUnauthorizedResponse() - return null - } - if (response.status !in 200..299) return null val settings = runCatching { json.decodeFromString(response.body) } .getOrNull() ?: return null storedState = storedState.copy( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt new file mode 100644 index 00000000..bf5edc94 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt @@ -0,0 +1,196 @@ +package com.nuvio.app.features.simkl + +import com.nuvio.app.features.addons.RawHttpResponse +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SimklApiClientTest { + @Test + fun `response classification retries only documented transient statuses`() { + assertEquals(SimklResponseAction.SUCCESS, classifySimklResponse(200)) + assertEquals(SimklResponseAction.REAUTHENTICATE, classifySimklResponse(401)) + assertEquals(SimklResponseAction.RETRY, classifySimklResponse(429)) + assertEquals(SimklResponseAction.RETRY, classifySimklResponse(500)) + assertEquals(SimklResponseAction.RETRY, classifySimklResponse(502)) + assertEquals(SimklResponseAction.RETRY, classifySimklResponse(503)) + assertEquals(SimklResponseAction.FAIL, classifySimklResponse(400)) + assertEquals(SimklResponseAction.FAIL, classifySimklResponse(409)) + assertEquals(SimklResponseAction.SOFT_SUCCESS, classifySimklResponse(409, true)) + assertEquals(SimklResponseAction.FAIL, classifySimklResponse(412)) + } + + @Test + fun `retry schedule uses exponential backoff and respects retry after`() { + assertEquals(1_000L, retryDelayMs(0, null, 0L)) + assertEquals(2_000L, retryDelayMs(1, null, 0L)) + assertEquals(4_000L, retryDelayMs(2, null, 0L)) + assertEquals(8_000L, retryDelayMs(3, null, 0L)) + assertEquals(16_000L, retryDelayMs(4, null, 0L)) + assertEquals(30_250L, retryDelayMs(0, "30", 250L)) + } + + @Test + fun `callers cannot override mandatory application metadata`() { + val url = buildSimklApiUrl( + path = "/sync/activities", + query = mapOf( + "client_id" to "spoofed", + "app-name" to "spoofed", + "app-version" to "spoofed", + ), + ) + + assertFalse("spoofed" in url) + } + + @Test + fun `every request carries metadata query and required headers`() = runBlocking { + val engine = RecordingEngine(response(200)) + val harness = TestHarness(engine) + + harness.client.execute( + SimklApiRequest( + method = SimklHttpMethod.GET, + path = "/sync/activities", + ), + ) + + val request = engine.requests.single() + assertTrue("client_id=" in request.url) + assertTrue("app-name=" in request.url) + assertTrue("app-version=" in request.url) + assertEquals("Bearer token", request.headers["Authorization"]) + assertEquals("application/json", request.headers["Accept"]) + assertTrue(request.headers.getValue("User-Agent").contains('/')) + } + + @Test + fun `authenticated requests are serialized at documented method rates`() = runBlocking { + val engine = RecordingEngine(response(200), response(200), response(200), response(200)) + val harness = TestHarness(engine) + + harness.client.execute(SimklApiRequest(SimklHttpMethod.GET, "/one")) + harness.client.execute(SimklApiRequest(SimklHttpMethod.GET, "/two")) + harness.client.execute(SimklApiRequest(SimklHttpMethod.POST, "/three", body = "{}")) + harness.client.execute(SimklApiRequest(SimklHttpMethod.POST, "/four", body = "{}")) + + assertEquals(listOf(100L, 1_000L), harness.sleeps) + assertEquals(listOf(0L, 100L, 100L, 1_100L), engine.requests.map { it.atEpochMs }) + } + + @Test + fun `transient responses retry sequentially and deterministic errors do not`() = runBlocking { + val transientEngine = RecordingEngine(response(503), response(502), response(200)) + val transientHarness = TestHarness(transientEngine) + + transientHarness.client.execute(SimklApiRequest(SimklHttpMethod.GET, "/retry")) + + assertEquals(listOf(1_000L, 2_000L), transientHarness.sleeps) + assertEquals(3, transientEngine.requests.size) + + val deterministicEngine = RecordingEngine(response(400, """{"error":"wrong_parameter","code":400}""")) + val deterministicHarness = TestHarness(deterministicEngine) + val error = assertFailsWith { + deterministicHarness.client.execute(SimklApiRequest(SimklHttpMethod.POST, "/bad", body = "{}")) + } + assertEquals("wrong_parameter", error.errorCode) + assertEquals(1, deterministicEngine.requests.size) + } + + @Test + fun `retry after and unauthorized handling are applied once`() = runBlocking { + val retryEngine = RecordingEngine( + response(429, headers = mapOf("Retry-After" to "3")), + response(200), + ) + val retryHarness = TestHarness(retryEngine) + retryHarness.client.execute(SimklApiRequest(SimklHttpMethod.GET, "/limited")) + assertEquals(listOf(3_000L), retryHarness.sleeps) + + val unauthorizedEngine = RecordingEngine(response(401)) + val unauthorizedHarness = TestHarness(unauthorizedEngine) + assertFailsWith { + unauthorizedHarness.client.execute(SimklApiRequest(SimklHttpMethod.GET, "/private")) + } + assertTrue(unauthorizedHarness.wasUnauthorized) + assertEquals(1, unauthorizedEngine.requests.size) + } + + @Test + fun `duplicate scrobble stop is a soft success`() = runBlocking { + val engine = RecordingEngine(response(409)) + val harness = TestHarness(engine) + + val result = harness.client.execute( + SimklApiRequest( + method = SimklHttpMethod.POST, + path = "/scrobble/stop", + body = "{}", + scrobbleStopConflictIsSuccess = true, + ), + ) + + assertEquals(409, result.status) + assertTrue(result.isSoftSuccess) + assertFalse(harness.wasUnauthorized) + } + + private class TestHarness(engine: RecordingEngine) { + var now = 0L + val sleeps = mutableListOf() + var wasUnauthorized = false + val client = SimklApiClient( + engine = engine.also { recording -> recording.now = { now } }, + accessToken = { "token" }, + onUnauthorized = { wasUnauthorized = true }, + nowEpochMs = { now }, + sleep = { delayMs -> + sleeps += delayMs + now += delayMs + }, + retryJitterMs = { 0L }, + ) + } + + private class RecordingEngine(vararg responses: RawHttpResponse) : SimklHttpEngine { + private val queuedResponses = responses.toMutableList() + val requests = mutableListOf() + var now: () -> Long = { 0L } + + override suspend fun execute( + method: String, + url: String, + headers: Map, + body: String, + ): RawHttpResponse { + requests += RecordedRequest(method, url, headers, body, now()) + return queuedResponses.removeAt(0) + } + } + + private data class RecordedRequest( + val method: String, + val url: String, + val headers: Map, + val body: String, + val atEpochMs: Long, + ) + + private companion object { + fun response( + status: Int, + body: String = "{}", + headers: Map = emptyMap(), + ) = RawHttpResponse( + status = status, + statusText = "", + url = "https://api.simkl.com/test", + body = body, + headers = headers, + ) + } +} 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 04/59] 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 2127e8b2..0cbc6856 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 c12fe5af..80052d89 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 00000000..798cf20d --- /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 a35fddc0..e510aa00 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 337d6968..6a373b8f 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 00000000..d7c846e6 --- /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 00000000..a31f531b --- /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 00000000..489daf16 --- /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 00000000..026791e1 --- /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 00000000..9f1f71e1 --- /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 c12bf602..43ce3299 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 00000000..89a0db68 --- /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 536492d8..9980e3bd 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 00000000..cbf92f28 --- /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)) + } +} From e4278a2391ecb0b741c24794d3ab5c6f0590a3fd Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:33:15 +0530 Subject: [PATCH 05/59] feat(simkl): add batched tracking mutations --- .../tracking/TrackingProviderBootstrap.kt | 2 + .../features/simkl/SimklMutationRepository.kt | 466 ++++++++++++++++++ .../app/features/tracking/TrackingMedia.kt | 125 +++++ .../app/features/tracking/TrackingProvider.kt | 54 ++ .../app/features/tracking/TrackingWrites.kt | 72 +++ .../simkl/SimklMutationRepositoryTest.kt | 196 ++++++++ .../features/tracking/TrackingMediaTest.kt | 35 ++ 7 files changed, 950 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingMedia.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingWrites.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingMediaTest.kt 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 e510aa00..e0ec3829 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,6 +1,7 @@ package com.nuvio.app.core.tracking import com.nuvio.app.features.simkl.SimklAuthRepository +import com.nuvio.app.features.simkl.SimklMutationRepository import com.nuvio.app.features.simkl.SimklSyncRepository import com.nuvio.app.features.trakt.TraktAuthRepository @@ -8,4 +9,5 @@ fun ensureTrackingProvidersRegistered() { TraktAuthRepository.descriptor SimklAuthRepository.descriptor SimklSyncRepository.state + SimklMutationRepository.ensureRegistered() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt new file mode 100644 index 00000000..2dc0e15c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt @@ -0,0 +1,466 @@ +package com.nuvio.app.features.simkl + +import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.tracking.TrackingEpisode +import com.nuvio.app.features.tracking.TrackingExternalIds +import com.nuvio.app.features.tracking.TrackingHistoryItem +import com.nuvio.app.features.tracking.TrackingHistoryWriter +import com.nuvio.app.features.tracking.TrackingListStatus +import com.nuvio.app.features.tracking.TrackingListWriter +import com.nuvio.app.features.tracking.TrackingMediaKind +import com.nuvio.app.features.tracking.TrackingMediaReference +import com.nuvio.app.features.tracking.TrackingMutationResult +import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.TrackingProviderRegistry +import com.nuvio.app.features.tracking.TrackingScrobbleAction +import com.nuvio.app.features.tracking.TrackingScrobbleEvent +import com.nuvio.app.features.tracking.TrackingScrobbler +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.put +import kotlin.math.round + +internal class SimklMutationService( + private val client: SimklApiClient, + private val onMutationCommitted: () -> Unit = {}, +) { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = false + explicitNulls = false + } + + suspend fun moveToList( + items: Collection, + destination: TrackingListStatus, + ): TrackingMutationResult { + val candidates = items.validated() + if (candidates.isEmpty()) return TrackingMutationResult(attemptedCount = 0) + val response = client.execute( + SimklApiRequest( + method = SimklHttpMethod.POST, + path = "/sync/add-to-list", + body = buildSimklListMutationBody(candidates, destination, json), + ), + ) + onMutationCommitted() + return response.toMutationResult(candidates.size, json) + } + + suspend fun removeFromList(items: Collection): TrackingMutationResult = + removeFromHistory(items) + + suspend fun addToHistory(items: Collection): TrackingMutationResult { + val candidates = items.toList().also { historyItems -> + require(historyItems.all { item -> item.media.hasResolvableIdentity }) { + "Simkl mutation requires a media ID or title for every item" + } + } + if (candidates.isEmpty()) return TrackingMutationResult(attemptedCount = 0) + val response = client.execute( + SimklApiRequest( + method = SimklHttpMethod.POST, + path = "/sync/history", + body = buildSimklHistoryMutationBody(candidates, json), + ), + ) + onMutationCommitted() + return response.toMutationResult(candidates.size, json) + } + + suspend fun removeFromHistory(items: Collection): TrackingMutationResult { + val candidates = items.validated() + if (candidates.isEmpty()) return TrackingMutationResult(attemptedCount = 0) + val response = client.execute( + SimklApiRequest( + method = SimklHttpMethod.POST, + path = "/sync/history/remove", + body = buildSimklHistoryRemovalBody(candidates, json), + ), + ) + onMutationCommitted() + return response.toMutationResult(candidates.size, json) + } + + suspend fun scrobble(action: TrackingScrobbleAction, event: TrackingScrobbleEvent) { + require(event.media.hasResolvableIdentity) { "Simkl scrobble requires a media ID or title" } + require(event.media.kind == TrackingMediaKind.MOVIE || event.media.episode != null) { + "Simkl series scrobble requires an episode" + } + client.execute( + SimklApiRequest( + method = SimklHttpMethod.POST, + path = "/scrobble/${action.wireValue}", + body = buildSimklScrobbleBody(event, json), + scrobbleStopConflictIsSuccess = action == TrackingScrobbleAction.STOP, + ), + ) + if (action != TrackingScrobbleAction.START) onMutationCommitted() + } + + private fun Collection.validated(): List = + toList().also { candidates -> + require(candidates.all(TrackingMediaReference::hasResolvableIdentity)) { + "Simkl mutation requires a media ID or title for every item" + } + } +} + +object SimklMutationRepository : TrackingListWriter, TrackingHistoryWriter, TrackingScrobbler { + override val providerId: TrackingProviderId = TrackingProviderId.SIMKL + + private val service by lazy { + SimklMutationService( + client = SimklApi.client, + onMutationCommitted = SimklSyncRepository::refreshAsync, + ) + } + + init { + TrackingProviderRegistry.registerListWriter(this) + TrackingProviderRegistry.registerHistoryWriter(this) + TrackingProviderRegistry.registerScrobbler(this) + } + + fun ensureRegistered() = Unit + + override suspend fun moveToList( + profileId: Int, + items: Collection, + destination: TrackingListStatus, + ): TrackingMutationResult { + if (!isActiveProfile(profileId)) return TrackingMutationResult(attemptedCount = 0) + return service.moveToList(items, destination) + } + + override suspend fun removeFromList( + profileId: Int, + items: Collection, + ): TrackingMutationResult { + if (!isActiveProfile(profileId)) return TrackingMutationResult(attemptedCount = 0) + return service.removeFromList(items) + } + + override suspend fun addToHistory( + profileId: Int, + items: Collection, + ): TrackingMutationResult { + if (!isActiveProfile(profileId)) return TrackingMutationResult(attemptedCount = 0) + return service.addToHistory(items) + } + + override suspend fun removeFromHistory( + profileId: Int, + items: Collection, + ): TrackingMutationResult { + if (!isActiveProfile(profileId)) return TrackingMutationResult(attemptedCount = 0) + return service.removeFromHistory(items) + } + + override suspend fun scrobble( + profileId: Int, + action: TrackingScrobbleAction, + event: TrackingScrobbleEvent, + ) { + if (!isActiveProfile(profileId)) return + service.scrobble(action, event) + } + + private fun isActiveProfile(profileId: Int): Boolean = ProfileRepository.activeProfileId == profileId +} + +internal fun buildSimklListMutationBody( + items: Collection, + destination: TrackingListStatus, + json: Json = SimklMutationJson, +): String { + val requestItems = items.map { item -> + item to SimklListItemDto( + to = destination.wireValue, + title = item.title.nonBlankOrNull(), + year = item.year, + ids = item.ids.toSimklJsonObjectOrNull(), + ) + } + return json.encodeToString( + SimklListMutationRequestDto( + movies = requestItems.filter { (item, _) -> item.kind == TrackingMediaKind.MOVIE }.map { it.second }, + shows = requestItems.filter { (item, _) -> item.kind != TrackingMediaKind.MOVIE }.map { it.second }, + ), + ) +} + +internal fun buildSimklHistoryMutationBody( + items: Collection, + json: Json = SimklMutationJson, +): String = json.encodeToString(buildHistoryRequest(items, includeWatchedAt = true)) + +internal fun buildSimklHistoryRemovalBody( + items: Collection, + json: Json = SimklMutationJson, +): String = json.encodeToString( + buildHistoryRequest( + items = items.map { media -> TrackingHistoryItem(media = media) }, + includeWatchedAt = false, + ), +) + +internal fun buildSimklScrobbleBody( + event: TrackingScrobbleEvent, + json: Json = SimklMutationJson, +): String { + val media = event.media.toScrobbleMediaDto() + val request = SimklScrobbleRequestDto( + progress = event.progressPercent.clampAndRoundProgress(), + movie = media.takeIf { event.media.kind == TrackingMediaKind.MOVIE }, + show = media.takeIf { event.media.kind == TrackingMediaKind.SHOW }, + anime = media.takeIf { event.media.kind == TrackingMediaKind.ANIME }, + episode = event.media.episode?.toEpisodeDto( + includeSeason = true, + includeWatchedAt = false, + watchedAtEpochMs = null, + ), + ) + return json.encodeToString(request) +} + +private fun buildHistoryRequest( + items: Collection, + includeWatchedAt: Boolean, +): SimklHistoryMutationRequestDto { + val movies = items + .filter { item -> item.media.kind == TrackingMediaKind.MOVIE } + .map { item -> + item.media.toHistoryItemDto( + watchedAtEpochMs = item.watchedAtEpochMs.takeIf { includeWatchedAt }, + includeWatchedAt = includeWatchedAt, + ) + } + val shows = items + .filter { item -> item.media.kind != TrackingMediaKind.MOVIE } + .groupBy { item -> item.media.stableKey } + .values + .map { matchingItems -> buildShowHistoryItem(matchingItems, includeWatchedAt) } + return SimklHistoryMutationRequestDto(movies = movies, shows = shows) +} + +private fun buildShowHistoryItem( + items: List, + includeWatchedAt: Boolean, +): SimklHistoryItemDto { + val first = items.first() + val parentMutation = items.lastOrNull { item -> item.media.episode == null } + if (parentMutation != null) { + return parentMutation.media.toHistoryItemDto( + watchedAtEpochMs = parentMutation.watchedAtEpochMs.takeIf { includeWatchedAt }, + includeWatchedAt = includeWatchedAt, + status = if (includeWatchedAt) TrackingListStatus.COMPLETED.wireValue else null, + ) + } + + val episodeMutations = items.mapNotNull { item -> + item.media.episode?.let { episode -> item to episode } + } + val flatEpisodes = episodeMutations + .filter { (_, episode) -> episode.season == null } + .map { (item, episode) -> + episode.toEpisodeDto( + includeSeason = false, + includeWatchedAt = includeWatchedAt, + watchedAtEpochMs = item.watchedAtEpochMs, + ) + } + .distinctBy(SimklEpisodeMutationDto::number) + val seasons = episodeMutations + .filter { (_, episode) -> episode.season != null } + .groupBy { (_, episode) -> requireNotNull(episode.season) } + .map { (season, seasonItems) -> + SimklSeasonMutationDto( + number = season, + episodes = seasonItems + .map { (item, episode) -> + episode.toEpisodeDto( + includeSeason = false, + includeWatchedAt = includeWatchedAt, + watchedAtEpochMs = item.watchedAtEpochMs, + ) + } + .distinctBy(SimklEpisodeMutationDto::number), + ) + } + .sortedBy(SimklSeasonMutationDto::number) + + return first.media.toHistoryItemDto( + watchedAtEpochMs = null, + includeWatchedAt = includeWatchedAt, + episodes = flatEpisodes, + seasons = seasons, + ) +} + +private fun TrackingMediaReference.toHistoryItemDto( + watchedAtEpochMs: Long?, + includeWatchedAt: Boolean, + status: String? = null, + episodes: List = emptyList(), + seasons: List = emptyList(), +): SimklHistoryItemDto = SimklHistoryItemDto( + title = title.nonBlankOrNull(), + year = year, + ids = ids.toSimklJsonObjectOrNull(), + watchedAt = watchedAtEpochMs.takeIf { includeWatchedAt }?.epochMsToUtcIso(), + status = status, + episodes = episodes, + seasons = seasons, +) + +private fun TrackingMediaReference.toScrobbleMediaDto(): SimklScrobbleMediaDto = + SimklScrobbleMediaDto( + title = title.nonBlankOrNull(), + year = year, + ids = ids.toSimklJsonObjectOrNull(), + ) + +private fun TrackingEpisode.toEpisodeDto( + includeSeason: Boolean, + includeWatchedAt: Boolean, + watchedAtEpochMs: Long?, +): SimklEpisodeMutationDto = SimklEpisodeMutationDto( + season = season.takeIf { includeSeason }, + number = number, + watchedAt = watchedAtEpochMs.takeIf { includeWatchedAt }?.epochMsToUtcIso(), +) + +private fun TrackingExternalIds.toSimklJsonObjectOrNull(): JsonObject? { + val value = buildJsonObject { + simkl?.let { put("simkl", it) } + imdb.nonBlankOrNull()?.let { put("imdb", it) } + tmdb?.let { put("tmdb", it) } + tvdb.nonBlankOrNull()?.let { tvdbValue -> + tvdbValue.toLongOrNull()?.let { put("tvdb", it) } ?: put("tvdb", tvdbValue) + } + mal?.let { put("mal", it) } + anidb?.let { put("anidb", it) } + anilist?.let { put("anilist", it) } + kitsu?.let { put("kitsu", it) } + } + return value.takeIf { it.isNotEmpty() } +} + +private fun SimklApiResponse.toMutationResult(attemptedCount: Int, json: Json): TrackingMutationResult { + val notFound = body + .takeIf(String::isNotBlank) + ?.let { payload -> runCatching { json.parseToJsonElement(payload).jsonObject["not_found"]?.jsonObject }.getOrNull() } + val notFoundCount = notFound + ?.values + ?.sumOf { value -> (value as? JsonArray)?.size ?: 0 } + ?: 0 + return TrackingMutationResult(attemptedCount = attemptedCount, notFoundCount = notFoundCount) +} + +private fun Double.clampAndRoundProgress(): Double = round(coerceIn(0.0, 100.0) * 100.0) / 100.0 + +private fun String?.nonBlankOrNull(): String? = this?.trim()?.takeIf(String::isNotEmpty) + +private fun Long.epochMsToUtcIso(): String? { + if (this < 10_000_000_000L) return null + val totalSeconds = this / 1_000L + val second = (totalSeconds % 60L).toInt() + val minute = ((totalSeconds / 60L) % 60L).toInt() + val hour = ((totalSeconds / 3_600L) % 24L).toInt() + var days = totalSeconds / 86_400L + var year = 1970 + while (true) { + val daysInYear = if (year.isLeapYear()) 366 else 365 + if (days < daysInYear) break + days -= daysInYear + year += 1 + } + val monthDays = if (year.isLeapYear()) { + intArrayOf(31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) + } else { + intArrayOf(31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) + } + var monthIndex = 0 + while (monthIndex < monthDays.size && days >= monthDays[monthIndex]) { + days -= monthDays[monthIndex] + monthIndex += 1 + } + val month = monthIndex + 1 + val day = days.toInt() + 1 + return "${year.pad(4)}-${month.pad(2)}-${day.pad(2)}T${hour.pad(2)}:${minute.pad(2)}:${second.pad(2)}Z" +} + +private fun Int.isLeapYear(): Boolean = (this % 4 == 0 && this % 100 != 0) || this % 400 == 0 +private fun Int.pad(length: Int): String = toString().padStart(length, '0') + +private val SimklMutationJson = Json { + encodeDefaults = false + explicitNulls = false +} + +@Serializable +private data class SimklListMutationRequestDto( + val movies: List = emptyList(), + val shows: List = emptyList(), +) + +@Serializable +private data class SimklListItemDto( + val to: String, + val title: String? = null, + val year: Int? = null, + val ids: JsonObject? = null, +) + +@Serializable +private data class SimklHistoryMutationRequestDto( + val movies: List = emptyList(), + val shows: List = emptyList(), +) + +@Serializable +private data class SimklHistoryItemDto( + val title: String? = null, + val year: Int? = null, + val ids: JsonObject? = null, + @SerialName("watched_at") val watchedAt: String? = null, + val status: String? = null, + val episodes: List = emptyList(), + val seasons: List = emptyList(), +) + +@Serializable +private data class SimklSeasonMutationDto( + val number: Int, + val episodes: List = emptyList(), +) + +@Serializable +private data class SimklEpisodeMutationDto( + val season: Int? = null, + val number: Int, + @SerialName("watched_at") val watchedAt: String? = null, +) + +@Serializable +private data class SimklScrobbleRequestDto( + val progress: Double, + val movie: SimklScrobbleMediaDto? = null, + val show: SimklScrobbleMediaDto? = null, + val anime: SimklScrobbleMediaDto? = null, + val episode: SimklEpisodeMutationDto? = null, +) + +@Serializable +private data class SimklScrobbleMediaDto( + val title: String? = null, + val year: Int? = null, + val ids: JsonObject? = null, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingMedia.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingMedia.kt new file mode 100644 index 00000000..8b23654a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingMedia.kt @@ -0,0 +1,125 @@ +package com.nuvio.app.features.tracking + +enum class TrackingMediaKind { + MOVIE, + SHOW, + ANIME, +} + +data class TrackingExternalIds( + val imdb: String? = null, + val tmdb: Long? = null, + val tvdb: String? = null, + val trakt: Long? = null, + val simkl: Long? = null, + val mal: Long? = null, + val anidb: Long? = null, + val anilist: Long? = null, + val kitsu: Long? = null, +) { + val hasAny: Boolean + get() = !imdb.isNullOrBlank() || + tmdb != null || + !tvdb.isNullOrBlank() || + trakt != null || + simkl != null || + mal != null || + anidb != null || + anilist != null || + kitsu != null + + fun mergeMissing(other: TrackingExternalIds): TrackingExternalIds = + TrackingExternalIds( + imdb = imdb?.takeIf(String::isNotBlank) ?: other.imdb, + tmdb = tmdb ?: other.tmdb, + tvdb = tvdb?.takeIf(String::isNotBlank) ?: other.tvdb, + trakt = trakt ?: other.trakt, + simkl = simkl ?: other.simkl, + mal = mal ?: other.mal, + anidb = anidb ?: other.anidb, + anilist = anilist ?: other.anilist, + kitsu = kitsu ?: other.kitsu, + ) +} + +data class TrackingEpisode( + val season: Int? = null, + val number: Int, + val title: String? = null, +) + +data class TrackingMediaReference( + val kind: TrackingMediaKind, + val title: String? = null, + val year: Int? = null, + val ids: TrackingExternalIds = TrackingExternalIds(), + val episode: TrackingEpisode? = null, +) { + val hasResolvableIdentity: Boolean + get() = ids.hasAny || !title.isNullOrBlank() + + val stableKey: String + get() = buildString { + append(kind.name.lowercase()) + append(':') + append( + ids.simkl?.let { "simkl:$it" } + ?: ids.imdb?.let { "imdb:$it" } + ?: ids.tmdb?.let { "tmdb:$it" } + ?: ids.tvdb?.let { "tvdb:$it" } + ?: ids.trakt?.let { "trakt:$it" } + ?: ids.mal?.let { "mal:$it" } + ?: ids.anidb?.let { "anidb:$it" } + ?: ids.anilist?.let { "anilist:$it" } + ?: ids.kitsu?.let { "kitsu:$it" } + ?: "title:${title.orEmpty().trim().lowercase()}:${year ?: 0}", + ) + } +} + +/** + * Parses the canonical IDs Nuvio receives from addons and tracker-backed lists. + * An unprefixed number retains its legacy Trakt meaning; it is never guessed to + * be a Simkl ID. + */ +fun parseTrackingExternalIds(rawValue: String?): TrackingExternalIds { + if (rawValue.isNullOrBlank()) return TrackingExternalIds() + val full = rawValue.trim() + + if (full.startsWith("tt", ignoreCase = true)) { + return TrackingExternalIds(imdb = full.substringBefore(':')) + } + + val prefix = full.substringBefore(':').lowercase() + val value = full.substringAfter(':', missingDelimiterValue = "").substringBefore(':').trim() + return when (prefix) { + "imdb" -> TrackingExternalIds(imdb = value.takeIf(String::isNotBlank)) + "tmdb" -> TrackingExternalIds(tmdb = value.toLongOrNull()) + "tvdb" -> TrackingExternalIds(tvdb = value.takeIf(String::isNotBlank)) + "trakt" -> TrackingExternalIds(trakt = value.toLongOrNull()) + "simkl" -> TrackingExternalIds(simkl = value.toLongOrNull()) + "mal" -> TrackingExternalIds(mal = value.toLongOrNull()) + "anidb" -> TrackingExternalIds(anidb = value.toLongOrNull()) + "anilist" -> TrackingExternalIds(anilist = value.toLongOrNull()) + "kitsu" -> TrackingExternalIds(kitsu = value.toLongOrNull()) + else -> TrackingExternalIds(trakt = full.toLongOrNull()) + } +} + +fun trackingMediaKind(contentType: String, ids: TrackingExternalIds = TrackingExternalIds()): TrackingMediaKind { + val normalized = contentType.trim().lowercase() + return when { + normalized in setOf("movie", "film") -> TrackingMediaKind.MOVIE + normalized == "anime" || ids.mal != null || ids.anidb != null || ids.anilist != null || ids.kitsu != null -> { + TrackingMediaKind.ANIME + } + else -> TrackingMediaKind.SHOW + } +} + +fun extractTrackingYear(value: String?): Int? = + value + ?.let { YEAR_PATTERN.find(it)?.value } + ?.toIntOrNull() + +private val YEAR_PATTERN = Regex("(19|20)\\d{2}") 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 43ce3299..8ca77e9c 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 @@ -60,6 +60,9 @@ object TrackingProviderRegistry { private val lock = SynchronizedObject() private val authProviders = mutableMapOf() private val profileStores = mutableSetOf() + private val listWriters = mutableMapOf() + private val historyWriters = mutableMapOf() + private val scrobblers = mutableMapOf() fun register(provider: TrackingAuthProvider) = synchronized(lock) { authProviders[provider.descriptor.id] = provider @@ -70,6 +73,18 @@ object TrackingProviderRegistry { profileStores += store } + fun registerListWriter(writer: TrackingListWriter) = synchronized(lock) { + listWriters[writer.providerId] = writer + } + + fun registerHistoryWriter(writer: TrackingHistoryWriter) = synchronized(lock) { + historyWriters[writer.providerId] = writer + } + + fun registerScrobbler(scrobbler: TrackingScrobbler) = synchronized(lock) { + scrobblers[scrobbler.providerId] = scrobbler + } + fun authProvider(id: TrackingProviderId): TrackingAuthProvider? = synchronized(lock) { authProviders[id] } @@ -88,6 +103,27 @@ object TrackingProviderRegistry { .filter { provider -> capability in provider.descriptor.capabilities } .sortedBy { provider -> provider.descriptor.id.ordinal } + fun listWriter(id: TrackingProviderId): TrackingListWriter? = synchronized(lock) { + listWriters[id] + } + + fun historyWriter(id: TrackingProviderId): TrackingHistoryWriter? = synchronized(lock) { + historyWriters[id] + } + + fun scrobbler(id: TrackingProviderId): TrackingScrobbler? = synchronized(lock) { + scrobblers[id] + } + + fun connectedListWriters(): List = + connectedPorts(listWriters, TrackingCapability.LIBRARY_WRITE) + + fun connectedHistoryWriters(): List = + connectedPorts(historyWriters, TrackingCapability.WATCHED_WRITE) + + fun connectedScrobblers(): List = + connectedPorts(scrobblers, TrackingCapability.SCROBBLE) + fun handleAuthCallback(url: String): Boolean = providersWith(TrackingCapability.AUTHENTICATION) .any { provider -> provider.handleAuthCallback(url) } @@ -118,4 +154,22 @@ object TrackingProviderRegistry { private fun profileStoreSnapshot(): List = synchronized(lock) { profileStores.toList() } + + private fun connectedPorts( + ports: Map, + capability: TrackingCapability, + ): List { + val candidates = synchronized(lock) { ports.entries.map { it.key to it.value } } + return candidates + .asSequence() + .filter { (id, _) -> + val provider = authProvider(id) + provider != null && + capability in provider.descriptor.capabilities && + provider.also(TrackingAuthProvider::ensureLoaded).isAuthenticated.value + } + .sortedBy { (id, _) -> id.ordinal } + .map { (_, port) -> port } + .toList() + } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingWrites.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingWrites.kt new file mode 100644 index 00000000..a3e24681 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingWrites.kt @@ -0,0 +1,72 @@ +package com.nuvio.app.features.tracking + +enum class TrackingListStatus(val wireValue: String) { + WATCHING("watching"), + PLAN_TO_WATCH("plantowatch"), + ON_HOLD("hold"), + COMPLETED("completed"), + DROPPED("dropped"), +} + +enum class TrackingScrobbleAction(val wireValue: String) { + START("start"), + PAUSE("pause"), + STOP("stop"), +} + +data class TrackingHistoryItem( + val media: TrackingMediaReference, + val watchedAtEpochMs: Long? = null, +) + +data class TrackingScrobbleEvent( + val media: TrackingMediaReference, + val progressPercent: Double, +) + +data class TrackingMutationResult( + val attemptedCount: Int, + val notFoundCount: Int = 0, +) { + val isComplete: Boolean + get() = notFoundCount == 0 +} + +interface TrackingListWriter { + val providerId: TrackingProviderId + + suspend fun moveToList( + profileId: Int, + items: Collection, + destination: TrackingListStatus, + ): TrackingMutationResult + + suspend fun removeFromList( + profileId: Int, + items: Collection, + ): TrackingMutationResult +} + +interface TrackingHistoryWriter { + val providerId: TrackingProviderId + + suspend fun addToHistory( + profileId: Int, + items: Collection, + ): TrackingMutationResult + + suspend fun removeFromHistory( + profileId: Int, + items: Collection, + ): TrackingMutationResult +} + +interface TrackingScrobbler { + val providerId: TrackingProviderId + + suspend fun scrobble( + profileId: Int, + action: TrackingScrobbleAction, + event: TrackingScrobbleEvent, + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt new file mode 100644 index 00000000..fcde5750 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt @@ -0,0 +1,196 @@ +package com.nuvio.app.features.simkl + +import com.nuvio.app.features.addons.RawHttpResponse +import com.nuvio.app.features.tracking.TrackingEpisode +import com.nuvio.app.features.tracking.TrackingExternalIds +import com.nuvio.app.features.tracking.TrackingHistoryItem +import com.nuvio.app.features.tracking.TrackingListStatus +import com.nuvio.app.features.tracking.TrackingMediaKind +import com.nuvio.app.features.tracking.TrackingMediaReference +import com.nuvio.app.features.tracking.TrackingScrobbleAction +import com.nuvio.app.features.tracking.TrackingScrobbleEvent +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SimklMutationRepositoryTest { + private val json = Json + + @Test + fun `list mutation batches types and puts destination on every item`() { + val body = buildSimklListMutationBody( + items = listOf(movie(), anime()), + destination = TrackingListStatus.PLAN_TO_WATCH, + ).asObject() + + assertNull(body["to"]) + val movie = body.getValue("movies").jsonArray.single().jsonObject + val anime = body.getValue("shows").jsonArray.single().jsonObject + assertEquals("plantowatch", movie.getValue("to").jsonPrimitive.content) + assertEquals("plantowatch", anime.getValue("to").jsonPrimitive.content) + assertEquals(53536L, movie.getValue("ids").jsonObject.getValue("simkl").jsonPrimitive.content.toLong()) + assertEquals("tt0181852", movie.getValue("ids").jsonObject.getValue("imdb").jsonPrimitive.content) + assertNull(movie.getValue("ids").jsonObject["trakt"]) + assertEquals(16498L, anime.getValue("ids").jsonObject.getValue("mal").jsonPrimitive.content.toLong()) + } + + @Test + fun `history mutation groups show episodes and keeps timestamps at event granularity`() { + val show = show(episode = TrackingEpisode(season = 1, number = 1)) + val secondEpisode = show.copy(episode = TrackingEpisode(season = 1, number = 2)) + val body = buildSimklHistoryMutationBody( + listOf( + TrackingHistoryItem(show, watchedAtEpochMs = 1_700_000_000_000L), + TrackingHistoryItem(secondEpisode, watchedAtEpochMs = 1_700_003_600_000L), + TrackingHistoryItem(movie(), watchedAtEpochMs = 1_700_000_000_000L), + ), + ).asObject() + + val showItem = body.getValue("shows").jsonArray.single().jsonObject + val episodes = showItem + .getValue("seasons").jsonArray.single().jsonObject + .getValue("episodes").jsonArray + assertEquals(2, episodes.size) + assertNull(episodes[0].jsonObject["season"]) + assertEquals("2023-11-14T22:13:20Z", episodes[0].jsonObject.getValue("watched_at").jsonPrimitive.content) + assertEquals("2023-11-14T23:13:20Z", episodes[1].jsonObject.getValue("watched_at").jsonPrimitive.content) + + val movieItem = body.getValue("movies").jsonArray.single().jsonObject + assertEquals("2023-11-14T22:13:20Z", movieItem.getValue("watched_at").jsonPrimitive.content) + } + + @Test + fun `anime removal uses shows array and contains no response-only or watch fields`() { + val body = buildSimklHistoryRemovalBody( + listOf(anime(TrackingEpisode(number = 4))), + ).asObject() + + assertNull(body["anime"]) + val item = body.getValue("shows").jsonArray.single().jsonObject + assertNull(item["watched_at"]) + assertNull(item["status"]) + assertEquals(4, item.getValue("episodes").jsonArray.single().jsonObject.getValue("number").jsonPrimitive.content.toInt()) + } + + @Test + fun `scrobble payload clamps progress and uses type-specific wrappers`() { + val movieBody = buildSimklScrobbleBody( + TrackingScrobbleEvent(movie(), progressPercent = 105.129), + ).asObject() + assertEquals(100.0, movieBody.getValue("progress").jsonPrimitive.content.toDouble()) + assertTrue("movie" in movieBody) + assertFalse("show" in movieBody) + + val animeBody = buildSimklScrobbleBody( + TrackingScrobbleEvent( + anime(TrackingEpisode(season = 2, number = 4)), + progressPercent = 42.236, + ), + ).asObject() + assertEquals(42.24, animeBody.getValue("progress").jsonPrimitive.content.toDouble()) + assertTrue("anime" in animeBody) + assertEquals(2, animeBody.getValue("episode").jsonObject.getValue("season").jsonPrimitive.content.toInt()) + assertEquals(4, animeBody.getValue("episode").jsonObject.getValue("number").jsonPrimitive.content.toInt()) + } + + @Test + fun `service reports partial not found and treats duplicate stop as committed`() = runBlocking { + val engine = RecordingEngine( + response( + status = 201, + body = """{"added":{"movies":1},"not_found":{"movies":[{"title":"Missing"}],"shows":[]}}""", + ), + response(status = 409), + ) + var now = 0L + var committed = 0 + val service = SimklMutationService( + client = SimklApiClient( + engine = engine, + accessToken = { "token" }, + onUnauthorized = {}, + nowEpochMs = { now }, + sleep = { duration -> now += duration }, + retryJitterMs = { 0L }, + ), + onMutationCommitted = { committed += 1 }, + ) + + val result = service.moveToList( + items = listOf(movie(), movie().copy(title = "Missing", ids = TrackingExternalIds())), + destination = TrackingListStatus.PLAN_TO_WATCH, + ) + service.scrobble( + action = TrackingScrobbleAction.STOP, + event = TrackingScrobbleEvent(movie(), progressPercent = 90.0), + ) + + assertEquals(2, result.attemptedCount) + assertEquals(1, result.notFoundCount) + assertFalse(result.isComplete) + assertEquals(listOf("/sync/add-to-list", "/scrobble/stop"), engine.paths) + assertEquals(2, committed) + } + + private fun String.asObject() = json.parseToJsonElement(this).jsonObject + + private fun movie() = TrackingMediaReference( + kind = TrackingMediaKind.MOVIE, + title = "Terminator 3: Rise of the Machines", + year = 2003, + ids = TrackingExternalIds( + simkl = 53536, + imdb = "tt0181852", + tmdb = 296, + trakt = 123, + ), + ) + + private fun show(episode: TrackingEpisode? = null) = TrackingMediaReference( + kind = TrackingMediaKind.SHOW, + title = "The Walking Dead", + year = 2010, + ids = TrackingExternalIds(simkl = 2090, imdb = "tt1520211", tvdb = "153021"), + episode = episode, + ) + + private fun anime(episode: TrackingEpisode? = null) = TrackingMediaReference( + kind = TrackingMediaKind.ANIME, + title = "Attack on Titan", + year = 2013, + ids = TrackingExternalIds(simkl = 39687, mal = 16498, anidb = 9541), + episode = episode, + ) + + private class RecordingEngine(vararg responses: RawHttpResponse) : SimklHttpEngine { + private val queued = responses.toMutableList() + val paths = mutableListOf() + + override suspend fun execute( + method: String, + url: String, + headers: Map, + body: String, + ): RawHttpResponse { + paths += url.substringAfter("api.simkl.com").substringBefore('?') + return queued.removeAt(0) + } + } + + private companion object { + fun response(status: Int, body: String = "{}") = RawHttpResponse( + status = status, + statusText = "", + url = "https://api.simkl.com/test", + body = body, + headers = emptyMap(), + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingMediaTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingMediaTest.kt new file mode 100644 index 00000000..009a7c4c --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingMediaTest.kt @@ -0,0 +1,35 @@ +package com.nuvio.app.features.tracking + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TrackingMediaTest { + @Test + fun `canonical addon ids parse without guessing an unprefixed Simkl id`() { + assertEquals("tt1520211", parseTrackingExternalIds("tt1520211:1:2").imdb) + assertEquals(1399L, parseTrackingExternalIds("tmdb:1399:1:2").tmdb) + assertEquals("153021", parseTrackingExternalIds("tvdb:153021").tvdb) + assertEquals(2090L, parseTrackingExternalIds("simkl:2090").simkl) + assertEquals(16498L, parseTrackingExternalIds("mal:16498").mal) + + val legacyNumeric = parseTrackingExternalIds("42") + assertEquals(42L, legacyNumeric.trakt) + assertEquals(null, legacyNumeric.simkl) + } + + @Test + fun `generic identity merges missing ids and classifies anime independently of ui type`() { + val ids = TrackingExternalIds(imdb = "tt2560140") + .mergeMissing(TrackingExternalIds(tmdb = 1429, mal = 16498)) + + assertEquals("tt2560140", ids.imdb) + assertEquals(1429L, ids.tmdb) + assertEquals(16498L, ids.mal) + assertTrue(ids.hasAny) + assertEquals(TrackingMediaKind.ANIME, trackingMediaKind("series", ids)) + assertEquals(TrackingMediaKind.MOVIE, trackingMediaKind("film")) + assertFalse(TrackingExternalIds().hasAny) + } +} From caf124e1b4ac2c99ae88d21e504bf130c7541e73 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:42:46 +0530 Subject: [PATCH 06/59] feat(simkl): add application state projections --- .../app/features/library/LibraryModels.kt | 3 + .../app/features/simkl/SimklProjections.kt | 306 ++++++++++++++++++ .../app/features/simkl/SimklSyncModels.kt | 6 +- .../app/features/tracking/TrackingProvider.kt | 41 ++- .../watchprogress/WatchProgressModels.kt | 1 + .../features/simkl/SimklProjectionsTest.kt | 192 +++++++++++ 6 files changed, 542 insertions(+), 7 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt index f8b0a8cf..254c7ff5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt @@ -24,6 +24,9 @@ data class LibraryItem( val imdbId: String? = null, val tmdbId: Int? = null, val traktId: Int? = null, + val trackingProviderId: String? = null, + val trackingProviderItemId: String? = null, + val trackingSourceUrl: String? = null, val savedAtEpochMs: Long, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt new file mode 100644 index 00000000..63b0fb22 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt @@ -0,0 +1,306 @@ +package com.nuvio.app.features.simkl + +import com.nuvio.app.features.home.PosterShape +import com.nuvio.app.features.library.LibraryItem +import com.nuvio.app.features.tracking.TrackingEpisode +import com.nuvio.app.features.tracking.TrackingExternalIds +import com.nuvio.app.features.tracking.TrackingMediaKind +import com.nuvio.app.features.tracking.TrackingMediaReference +import com.nuvio.app.features.tracking.extractTrackingYear +import com.nuvio.app.features.tracking.parseTrackingExternalIds +import com.nuvio.app.features.tracking.trackingMediaKind +import com.nuvio.app.features.watched.WatchedItem +import com.nuvio.app.features.watched.watchedItemKey +import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watchprogress.WatchProgressSourceSimklPlayback +import com.nuvio.app.features.watchprogress.buildPlaybackVideoId + +internal const val SIMKL_WATCHLIST_KEY = "simkl:watchlist" +internal const val SIMKL_WATCHLIST_TITLE = "Simkl Watchlist" + +internal data class SimklWatchedProjection( + val items: List, + val fullyWatchedSeriesKeys: Set, +) + +internal fun SimklSyncSnapshot.toSimklLibraryItems(): List = + entries + .asSequence() + .filter { entry -> entry.status == SimklListStatus.PLAN_TO_WATCH } + .mapNotNull { entry -> entry.toLibraryItem(lastSyncedAtEpochMs) } + .sortedByDescending(LibraryItem::savedAtEpochMs) + .toList() + +internal fun SimklSyncSnapshot.toSimklWatchedProjection(): SimklWatchedProjection { + val watchedItems = mutableListOf() + val fullyWatched = linkedSetOf() + + entries.forEach { entry -> + val media = entry.media ?: return@forEach + val contentId = media.canonicalContentId() ?: return@forEach + val contentType = if (entry.mediaType == SimklMediaType.MOVIES) "movie" else "series" + val title = media.title?.takeIf(String::isNotBlank) ?: contentId + val poster = simklPosterUrl(media.poster) + val lastWatchedAt = parseSimklUtcEpochMs(entry.lastWatchedAt) + ?: parseSimklUtcEpochMs(entry.addedToWatchlistAt) + ?: 0L + + if (entry.mediaType == SimklMediaType.MOVIES) { + if (entry.lastWatchedAt != null || entry.status == SimklListStatus.COMPLETED) { + watchedItems += WatchedItem( + id = contentId, + type = contentType, + name = title, + poster = poster, + releaseInfo = media.year?.toString(), + markedAtEpochMs = lastWatchedAt, + ) + } + return@forEach + } + + var hasEpisodeHistory = false + entry.seasons.forEach seasonLoop@{ season -> + val seasonNumber = season.number ?: return@seasonLoop + season.episodes.forEach episodeLoop@{ episode -> + val episodeNumber = episode.number ?: return@episodeLoop + val watchedAt = parseSimklUtcEpochMs(episode.watchedAt) ?: return@episodeLoop + hasEpisodeHistory = true + watchedItems += WatchedItem( + id = contentId, + type = contentType, + name = title, + poster = poster, + releaseInfo = media.year?.toString(), + season = seasonNumber, + episode = episodeNumber, + markedAtEpochMs = watchedAt, + ) + } + } + + if (entry.status == SimklListStatus.COMPLETED) { + fullyWatched += watchedItemKey(contentType, contentId) + if (!hasEpisodeHistory) { + watchedItems += WatchedItem( + id = contentId, + type = contentType, + name = title, + poster = poster, + releaseInfo = media.year?.toString(), + markedAtEpochMs = lastWatchedAt, + ) + } + } + } + + val newestByKey = watchedItems + .groupBy { item -> watchedItemKey(item.type, item.id, item.season, item.episode) } + .mapNotNull { (_, candidates) -> candidates.maxByOrNull(WatchedItem::markedAtEpochMs) } + .sortedByDescending(WatchedItem::markedAtEpochMs) + return SimklWatchedProjection( + items = newestByKey, + fullyWatchedSeriesKeys = fullyWatched, + ) +} + +internal fun SimklSyncSnapshot.toSimklProgressEntries(): List = + playback + .mapNotNull(SimklPlaybackSession::toWatchProgressEntry) + .groupBy(WatchProgressEntry::progressKey) + .mapNotNull { (_, candidates) -> candidates.maxByOrNull(WatchProgressEntry::lastUpdatedEpochMs) } + .sortedByDescending(WatchProgressEntry::lastUpdatedEpochMs) + +internal fun SimklSyncSnapshot.mediaReference( + contentId: String, + contentType: String, + title: String? = null, + releaseInfo: String? = null, + season: Int? = null, + episode: Int? = null, + episodeTitle: String? = null, +): TrackingMediaReference { + val entry = entries.firstOrNull { candidate -> candidate.matchesContentId(contentId) } + val media = entry?.media + val parsedIds = parseTrackingExternalIds(contentId) + val ids = media?.toTrackingExternalIds()?.mergeMissing(parsedIds) ?: parsedIds + val kind = when (entry?.mediaType) { + SimklMediaType.MOVIES -> TrackingMediaKind.MOVIE + SimklMediaType.ANIME -> TrackingMediaKind.ANIME + SimklMediaType.SHOWS -> TrackingMediaKind.SHOW + null -> trackingMediaKind(contentType, ids) + } + return TrackingMediaReference( + kind = kind, + title = media?.title?.takeIf(String::isNotBlank) ?: title?.takeIf(String::isNotBlank), + year = media?.year ?: extractTrackingYear(releaseInfo), + ids = ids, + episode = episode?.let { number -> + TrackingEpisode(season = season, number = number, title = episodeTitle) + }, + ) +} + +internal fun SimklMedia.toTrackingExternalIds(): TrackingExternalIds = TrackingExternalIds( + simkl = ids.simklIdValue()?.toLongOrNull(), + imdb = ids.idValue("imdb"), + tmdb = ids.idValue("tmdb")?.toLongOrNull(), + tvdb = ids.idValue("tvdb"), + mal = ids.idValue("mal")?.toLongOrNull(), + anidb = ids.idValue("anidb")?.toLongOrNull(), + anilist = ids.idValue("anilist")?.toLongOrNull(), + kitsu = ids.idValue("kitsu")?.toLongOrNull(), +) + +internal fun SimklMedia.canonicalContentId(): String? = when { + !ids.idValue("imdb").isNullOrBlank() -> ids.idValue("imdb") + !ids.idValue("tmdb").isNullOrBlank() -> "tmdb:${ids.idValue("tmdb")}" + !ids.idValue("tvdb").isNullOrBlank() -> "tvdb:${ids.idValue("tvdb")}" + !ids.idValue("mal").isNullOrBlank() -> "mal:${ids.idValue("mal")}" + !ids.idValue("anidb").isNullOrBlank() -> "anidb:${ids.idValue("anidb")}" + !ids.idValue("anilist").isNullOrBlank() -> "anilist:${ids.idValue("anilist")}" + !ids.idValue("kitsu").isNullOrBlank() -> "kitsu:${ids.idValue("kitsu")}" + !ids.simklIdValue().isNullOrBlank() -> "simkl:${ids.simklIdValue()}" + else -> null +} + +internal fun simklPosterUrl(path: String?): String? = + path + ?.trim() + ?.trim('/') + ?.takeIf(String::isNotBlank) + ?.let { normalized -> "https://wsrv.nl/?url=https://simkl.in/posters/${normalized}_w.webp&q=90" } + +internal fun buildSimklSourceUrl(mediaType: SimklMediaType, media: SimklMedia): String? { + val id = media.ids.simklIdValue()?.toLongOrNull()?.takeIf { it > 0L } ?: return null + val category = when (mediaType) { + SimklMediaType.MOVIES -> "movies" + SimklMediaType.SHOWS -> "tv" + SimklMediaType.ANIME -> "anime" + } + val slug = media.ids.idValue("slug") + ?.trim() + ?.trim('/') + ?.takeIf { value -> value.isNotBlank() && value.all { it.isLetterOrDigit() || it in "-_." } } + return if (slug == null) { + "https://simkl.com/$category/$id" + } else { + "https://simkl.com/$category/$id/$slug" + } +} + +internal fun parseSimklUtcEpochMs(value: String?): Long? { + val match = value?.trim()?.let(SIMKL_UTC_PATTERN::matchEntire) ?: return null + val year = match.groupValues[1].toIntOrNull() ?: return null + val month = match.groupValues[2].toIntOrNull() ?: return null + val day = match.groupValues[3].toIntOrNull() ?: return null + val hour = match.groupValues[4].toIntOrNull() ?: return null + val minute = match.groupValues[5].toIntOrNull() ?: return null + val second = match.groupValues[6].toIntOrNull() ?: return null + val millis = match.groupValues[7].padEnd(3, '0').take(3).toIntOrNull() ?: 0 + if (year < 1970 || month !in 1..12 || hour !in 0..23 || minute !in 0..59 || second !in 0..59) return null + val monthDays = daysInMonths(year) + if (day !in 1..monthDays[month - 1]) return null + + var days = 0L + for (currentYear in 1970 until year) days += if (currentYear.isLeapYear()) 366L else 365L + for (monthIndex in 0 until month - 1) days += monthDays[monthIndex] + days += day - 1L + return (((days * 24L + hour) * 60L + minute) * 60L + second) * 1_000L + millis +} + +private fun SimklLibraryEntry.toLibraryItem(lastSyncedAtEpochMs: Long?): LibraryItem? { + val media = media ?: return null + val contentId = media.canonicalContentId() ?: return null + val simklId = media.ids.simklIdValue()?.toLongOrNull() + return LibraryItem( + id = contentId, + type = if (mediaType == SimklMediaType.MOVIES) "movie" else "series", + name = media.title?.takeIf(String::isNotBlank) ?: contentId, + poster = simklPosterUrl(media.poster), + releaseInfo = media.year?.toString(), + posterShape = PosterShape.Poster, + listKeys = setOf(SIMKL_WATCHLIST_KEY), + imdbId = media.ids.idValue("imdb"), + tmdbId = media.ids.idValue("tmdb")?.toIntOrNull(), + trackingProviderId = "simkl", + trackingProviderItemId = simklId?.let { "simkl:$it" }, + trackingSourceUrl = buildSimklSourceUrl(mediaType, media), + savedAtEpochMs = parseSimklUtcEpochMs(addedToWatchlistAt) + ?: lastSyncedAtEpochMs + ?: 0L, + ) +} + +private fun SimklPlaybackSession.toWatchProgressEntry(): WatchProgressEntry? { + val media = media ?: return null + val parentId = media.canonicalContentId() ?: return null + val isMovie = mediaType == SimklMediaType.MOVIES + val season = episode?.tvdbSeason ?: episode?.season + val episodeNumber = episode?.tvdbNumber ?: episode?.number + if (!isMovie && episodeNumber == null) return null + val videoId = if (isMovie) { + parentId + } else { + buildPlaybackVideoId( + parentMetaId = parentId, + seasonNumber = season, + episodeNumber = episodeNumber, + ) + } + val normalizedProgress = progress.coerceIn(0.0, 100.0) + val durationMs = media.runtime?.takeIf { it > 0 }?.toLong()?.times(60_000L) ?: 0L + val positionMs = if (durationMs > 0L) (durationMs * normalizedProgress / 100.0).toLong() else 0L + val updatedAt = parseSimklUtcEpochMs(pausedAt) + ?: parseSimklUtcEpochMs(watchedAt) + ?: 0L + return WatchProgressEntry( + contentType = if (isMovie) "movie" else "series", + parentMetaId = parentId, + parentMetaType = if (isMovie) "movie" else "series", + videoId = videoId, + title = media.title?.takeIf(String::isNotBlank) ?: parentId, + poster = simklPosterUrl(media.poster), + seasonNumber = season, + episodeNumber = episodeNumber, + episodeTitle = episode?.title, + lastPositionMs = positionMs, + durationMs = durationMs, + lastUpdatedEpochMs = updatedAt, + isCompleted = normalizedProgress >= SIMKL_WATCHED_THRESHOLD_PERCENT, + progressPercent = normalizedProgress.toFloat(), + source = WatchProgressSourceSimklPlayback, + progressKey = id?.let { "simkl-playback:$it" } + ?: "simkl-playback:$parentId:${season ?: -1}:${episodeNumber ?: -1}", + ) +} + +private fun SimklLibraryEntry.matchesContentId(contentId: String): Boolean { + val media = media ?: return false + if (media.canonicalContentId().equals(contentId, ignoreCase = true)) return true + val parsed = parseTrackingExternalIds(contentId) + val candidateIds = media.toTrackingExternalIds() + return (parsed.simkl != null && parsed.simkl == candidateIds.simkl) || + (!parsed.imdb.isNullOrBlank() && parsed.imdb.equals(candidateIds.imdb, ignoreCase = true)) || + (parsed.tmdb != null && parsed.tmdb == candidateIds.tmdb) || + (!parsed.tvdb.isNullOrBlank() && parsed.tvdb.equals(candidateIds.tvdb, ignoreCase = true)) || + (parsed.mal != null && parsed.mal == candidateIds.mal) || + (parsed.anidb != null && parsed.anidb == candidateIds.anidb) || + (parsed.anilist != null && parsed.anilist == candidateIds.anilist) || + (parsed.kitsu != null && parsed.kitsu == candidateIds.kitsu) +} + +private fun Int.isLeapYear(): Boolean = (this % 4 == 0 && this % 100 != 0) || this % 400 == 0 + +private fun daysInMonths(year: Int): IntArray = if (year.isLeapYear()) { + intArrayOf(31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) +} else { + intArrayOf(31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) +} + +private val SIMKL_UTC_PATTERN = Regex( + "^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})(?:\\.(\\d{1,9}))?Z$", + RegexOption.IGNORE_CASE, +) + +private const val SIMKL_WATCHED_THRESHOLD_PERCENT = 80.0 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 index a31f531b..68f12fa9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncModels.kt @@ -187,7 +187,11 @@ data class SimklSyncUiState( internal fun Map.idValue(key: String): String? = get(key)?.jsonPrimitive?.content?.trim()?.takeIf(String::isNotBlank) +internal fun Map.simklIdValue(): String? = + idValue("simkl") ?: idValue("simkl_id") + private fun Map.stableMediaId(): String? { - val keys = listOf("simkl", "imdb", "tmdb", "tvdb", "mal", "anidb", "anilist", "kitsu") + simklIdValue()?.let { return "simkl:$it" } + val keys = listOf("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/tracking/TrackingProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingProvider.kt index 8ca77e9c..4c0d0619 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 @@ -1,6 +1,15 @@ package com.nuvio.app.features.tracking +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.atomicfu.locks.SynchronizedObject import kotlinx.atomicfu.locks.synchronized @@ -58,15 +67,34 @@ interface TrackingAuthProvider : TrackingProfileStore { object TrackingProviderRegistry { private val lock = SynchronizedObject() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val authProviders = mutableMapOf() + private val authObservationJobs = mutableMapOf() private val profileStores = mutableSetOf() private val listWriters = mutableMapOf() private val historyWriters = mutableMapOf() private val scrobblers = mutableMapOf() - fun register(provider: TrackingAuthProvider) = synchronized(lock) { - authProviders[provider.descriptor.id] = provider - profileStores += provider + private val _connectedProviderIds = MutableStateFlow>(emptySet()) + val connectedProviderIds: StateFlow> = _connectedProviderIds.asStateFlow() + + fun register(provider: TrackingAuthProvider) { + val previousJob = synchronized(lock) { + authProviders[provider.descriptor.id] = provider + profileStores += provider + authObservationJobs.remove(provider.descriptor.id) + } + previousJob?.cancel() + val observationJob = scope.launch { + provider.isAuthenticated.collectLatest { isAuthenticated -> + _connectedProviderIds.update { connected -> + if (isAuthenticated) connected + provider.providerId else connected - provider.providerId + } + } + } + synchronized(lock) { + authObservationJobs[provider.descriptor.id] = observationJob + } } fun registerProfileStore(store: TrackingProfileStore) = synchronized(lock) { @@ -92,11 +120,12 @@ object TrackingProviderRegistry { fun isAuthenticated(id: TrackingProviderId): Boolean = authProvider(id)?.also(TrackingAuthProvider::ensureLoaded)?.isAuthenticated?.value == true - fun connectedProviderIds(): Set = - providerSnapshot() - .onEach(TrackingAuthProvider::ensureLoaded) + fun connectedProviderIdsSnapshot(): Set { + providerSnapshot().forEach(TrackingAuthProvider::ensureLoaded) + return providerSnapshot() .filterTo(linkedSetOf()) { provider -> provider.isAuthenticated.value } .mapTo(linkedSetOf()) { provider -> provider.descriptor.id } + } fun providersWith(capability: TrackingCapability): List = providerSnapshot() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt index 0314e65a..b4e89249 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt @@ -12,6 +12,7 @@ internal const val WatchProgressSourceLocal = "local" internal const val WatchProgressSourceTraktPlayback = "trakt_playback" internal const val WatchProgressSourceTraktHistory = "trakt_history" internal const val WatchProgressSourceTraktShowProgress = "trakt_show_progress" +internal const val WatchProgressSourceSimklPlayback = "simkl_playback" @Serializable enum class ContinueWatchingSectionStyle { diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt new file mode 100644 index 00000000..118204e7 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt @@ -0,0 +1,192 @@ +package com.nuvio.app.features.simkl + +import com.nuvio.app.features.tracking.TrackingMediaKind +import com.nuvio.app.features.watchprogress.WatchProgressSourceSimklPlayback +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SimklProjectionsTest { + @Test + fun `library projection exposes only plan to watch with attribution`() { + val plan = entry( + type = SimklMediaType.MOVIES, + status = SimklListStatus.PLAN_TO_WATCH, + id = 53536, + imdb = "tt0181852", + slug = "terminator-3-rise-of-the-machines", + addedAt = "2023-11-14T22:13:20Z", + ) + val completed = entry( + type = SimklMediaType.MOVIES, + status = SimklListStatus.COMPLETED, + id = 53434, + imdb = "tt0068646", + ) + + val items = SimklSyncSnapshot(entries = listOf(plan, completed)).toSimklLibraryItems() + + val item = items.single() + assertEquals("tt0181852", item.id) + assertEquals(setOf(SIMKL_WATCHLIST_KEY), item.listKeys) + assertEquals("simkl", item.trackingProviderId) + assertEquals("simkl:53536", item.trackingProviderItemId) + assertEquals( + "https://simkl.com/movies/53536/terminator-3-rise-of-the-machines", + item.trackingSourceUrl, + ) + assertTrue(item.poster.orEmpty().contains("simkl.in/posters/12/poster_w.webp")) + assertEquals(1_700_000_000_000L, item.savedAtEpochMs) + } + + @Test + fun `watched projection includes movie events rich episodes and completed series marker`() { + val movie = entry( + type = SimklMediaType.MOVIES, + status = SimklListStatus.COMPLETED, + id = 53536, + imdb = "tt0181852", + lastWatchedAt = "2023-11-14T22:13:20Z", + ) + val richShow = entry( + type = SimklMediaType.SHOWS, + status = SimklListStatus.WATCHING, + id = 2090, + imdb = "tt1520211", + seasons = listOf( + SimklSeason( + number = 1, + episodes = listOf( + SimklEpisode(number = 1, watchedAt = "2023-11-14T23:13:20Z"), + SimklEpisode(number = 2, watchedAt = null), + ), + ), + ), + ) + val summaryOnlyCompletedShow = entry( + type = SimklMediaType.ANIME, + status = SimklListStatus.COMPLETED, + id = 39687, + imdb = "tt2560140", + lastWatchedAt = "2023-11-15T00:13:20Z", + ) + + val projection = SimklSyncSnapshot( + entries = listOf(movie, richShow, summaryOnlyCompletedShow), + ).toSimklWatchedProjection() + + assertEquals(3, projection.items.size) + assertNotNull(projection.items.singleOrNull { it.type == "movie" }) + val episode = projection.items.single { it.season == 1 && it.episode == 1 } + assertEquals("tt1520211", episode.id) + assertFalse(projection.items.any { it.episode == 2 }) + assertTrue(projection.items.any { it.id == "tt2560140" && it.season == null }) + assertTrue(projection.fullyWatchedSeriesKeys.any { "tt2560140" in it }) + } + + @Test + fun `playback projection preserves Simkl session identity and percentage`() { + val session = SimklPlaybackSession( + id = 12345, + progress = 42.2, + pausedAt = "2024-04-30T22:13:00.250Z", + type = "episode", + episode = SimklPlaybackEpisode( + season = 1, + number = 3, + title = "Chapter Three", + ), + show = media(id = 39687, imdb = "tt4574334", runtime = 50), + ) + + val entry = SimklSyncSnapshot(playback = listOf(session)).toSimklProgressEntries().single() + + assertEquals("tt4574334", entry.parentMetaId) + assertEquals(1, entry.seasonNumber) + assertEquals(3, entry.episodeNumber) + assertEquals(42.2f, entry.progressPercent) + assertEquals(3_000_000L, entry.durationMs) + assertEquals(1_266_000L, entry.lastPositionMs) + assertEquals("simkl-playback:12345", entry.progressKey) + assertEquals(WatchProgressSourceSimklPlayback, entry.source) + assertFalse(entry.isCompleted) + assertEquals(1_714_515_180_250L, entry.lastUpdatedEpochMs) + } + + @Test + fun `media reference retains anime catalog and all accepted ids`() { + val anime = entry( + type = SimklMediaType.ANIME, + status = SimklListStatus.WATCHING, + id = 39687, + imdb = "tt2560140", + mal = 16498, + ) + val snapshot = SimklSyncSnapshot(entries = listOf(anime)) + + val reference = snapshot.mediaReference( + contentId = "tt2560140", + contentType = "series", + season = 2, + episode = 4, + ) + + assertEquals(TrackingMediaKind.ANIME, reference.kind) + assertEquals(39687L, reference.ids.simkl) + assertEquals(16498L, reference.ids.mal) + assertEquals(2, reference.episode?.season) + assertEquals(4, reference.episode?.number) + } + + @Test + fun `timestamp parser accepts UTC fractions and rejects invalid calendar values`() { + assertEquals(0L, parseSimklUtcEpochMs("1970-01-01T00:00:00Z")) + assertEquals(951_782_400_123L, parseSimklUtcEpochMs("2000-02-29T00:00:00.123Z")) + assertNull(parseSimklUtcEpochMs("2023-02-29T00:00:00Z")) + assertNull(parseSimklUtcEpochMs("2024-01-01T00:00:00+01:00")) + } + + private fun entry( + type: SimklMediaType, + status: SimklListStatus, + id: Long, + imdb: String? = null, + mal: Long? = null, + slug: String? = null, + addedAt: String? = null, + lastWatchedAt: String? = null, + seasons: List = emptyList(), + ): SimklLibraryEntry = SimklLibraryEntry( + mediaType = type, + status = status, + addedToWatchlistAt = addedAt, + lastWatchedAt = lastWatchedAt, + seasons = seasons, + movie = if (type == SimklMediaType.MOVIES) media(id, imdb, mal, slug = slug) else null, + show = if (type != SimklMediaType.MOVIES) media(id, imdb, mal, slug = slug) else null, + ) + + private fun media( + id: Long, + imdb: String? = null, + mal: Long? = null, + runtime: Int? = null, + slug: String? = null, + ): SimklMedia = SimklMedia( + title = "Title $id", + poster = "12/poster", + year = 2020, + runtime = runtime, + ids = buildJsonObject { + put("simkl", id) + imdb?.let { put("imdb", it) } + mal?.let { put("mal", it) } + slug?.let { put("slug", it) } + }, + ) +} From 0f12e61430ea44b2139b8c710855899c022ca7c8 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:54:08 +0530 Subject: [PATCH 07/59] feat(simkl): wire tracker state into app repositories --- .../tracking/TrackingProviderBootstrap.kt | 4 + .../app/features/library/LibraryRepository.kt | 291 +++++++++++++----- .../simkl/SimklApplicationAdapters.kt | 236 ++++++++++++++ .../app/features/simkl/SimklSyncRepository.kt | 11 + .../app/features/watched/WatchedRepository.kt | 122 +++++--- .../watching/sync/WatchedSyncAdapter.kt | 2 + .../watchprogress/WatchProgressRepository.kt | 180 ++++++++--- .../WatchProgressSourceCoordinator.kt | 23 +- .../features/watched/WatchedRepositoryTest.kt | 18 ++ .../WatchProgressIdentityTest.kt | 30 ++ 10 files changed, 757 insertions(+), 160 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt 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 e0ec3829..26f45848 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 @@ -2,6 +2,8 @@ package com.nuvio.app.core.tracking import com.nuvio.app.features.simkl.SimklAuthRepository import com.nuvio.app.features.simkl.SimklMutationRepository +import com.nuvio.app.features.simkl.SimklLibraryRepository +import com.nuvio.app.features.simkl.SimklProgressRepository import com.nuvio.app.features.simkl.SimklSyncRepository import com.nuvio.app.features.trakt.TraktAuthRepository @@ -9,5 +11,7 @@ fun ensureTrackingProvidersRegistered() { TraktAuthRepository.descriptor SimklAuthRepository.descriptor SimklSyncRepository.state + SimklLibraryRepository.uiState + SimklProgressRepository.uiState SimklMutationRepository.ensureRegistered() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt index 06c102f8..3aa17583 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt @@ -2,20 +2,25 @@ package com.nuvio.app.features.library import co.touchlab.kermit.Logger import com.nuvio.app.core.auth.AuthRepository -import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.core.sync.putSyncOriginClientId +import com.nuvio.app.core.tracking.ensureTrackingProvidersRegistered +import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.simkl.SIMKL_WATCHLIST_KEY +import com.nuvio.app.features.simkl.SIMKL_WATCHLIST_TITLE +import com.nuvio.app.features.simkl.SimklLibraryRepository +import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.TrackingProviderRegistry +import com.nuvio.app.features.tracking.effectiveLibrarySourceMode as resolveEffectiveLibrarySourceMode import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktLibraryRepository import com.nuvio.app.features.trakt.TraktListTab import com.nuvio.app.features.trakt.TraktListType import com.nuvio.app.features.trakt.TraktMembershipChanges import com.nuvio.app.features.trakt.TraktSettingsRepository -import com.nuvio.app.features.trakt.effectiveLibrarySourceMode as resolveEffectiveLibrarySourceMode -import com.nuvio.app.features.trakt.shouldUseTraktLibrary import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.rpc import kotlinx.atomicfu.locks.SynchronizedObject @@ -93,14 +98,21 @@ object LibraryRepository { init { syncScope.launch { - TraktAuthRepository.isAuthenticated.collectLatest { authenticated -> - if (authenticated) { + TrackingProviderRegistry.connectedProviderIds.collectLatest { connectedProviderIds -> + if (TrackingProviderId.TRAKT in connectedProviderIds) { TraktLibraryRepository.preloadListTabsAsync() - if (shouldUseTraktLibrary(authenticated, selectedLibrarySourceMode())) { + if (effectiveLibrarySourceMode() == LibrarySourceMode.TRAKT) { runCatching { TraktLibraryRepository.refreshNow() } .onFailure { log.e(it) { "Failed to refresh Trakt library after auth change" } } } } + if ( + TrackingProviderId.SIMKL in connectedProviderIds && + effectiveLibrarySourceMode() == LibrarySourceMode.SIMKL + ) { + runCatching { SimklLibraryRepository.refreshNow() } + .onFailure { log.e(it) { "Failed to refresh Simkl library after auth change" } } + } publish() } } @@ -109,12 +121,17 @@ object LibraryRepository { .map { it.librarySourceMode } .distinctUntilChanged() .collectLatest { source -> - if (shouldUseTraktLibrary(TraktAuthRepository.isAuthenticated.value, source)) { - TraktLibraryRepository.preloadListTabsAsync() - publish() - refreshTraktLibraryAsync() - } else { - publish() + when (resolveEffectiveLibrarySourceMode(source, TrackingProviderRegistry::isAuthenticated)) { + LibrarySourceMode.TRAKT -> { + TraktLibraryRepository.preloadListTabsAsync() + publish() + refreshTraktLibraryAsync() + } + LibrarySourceMode.SIMKL -> { + publish() + refreshSimklLibraryAsync() + } + LibrarySourceMode.LOCAL -> publish() } } } @@ -125,24 +142,34 @@ object LibraryRepository { } } } + syncScope.launch { + SimklLibraryRepository.uiState.collectLatest { + if (TrackingProviderRegistry.isAuthenticated(TrackingProviderId.SIMKL)) { + publish() + } + } + } } fun ensureLoaded() { - TraktAuthRepository.ensureLoaded() + ensureTrackingProvidersRegistered() + TrackingProviderRegistry.ensureLoaded() TraktSettingsRepository.ensureLoaded() TraktLibraryRepository.ensureLoaded() + SimklLibraryRepository.ensureLoaded() while (true) { val activeProfileId = ProfileRepository.activeProfileId val snapshot = localState.snapshot() if (snapshot.hasLoaded && snapshot.token.profileId == activeProfileId) break loadFromDisk(activeProfileId) } - if (TraktAuthRepository.isAuthenticated.value) { + if (TrackingProviderRegistry.isAuthenticated(TrackingProviderId.TRAKT)) { TraktLibraryRepository.preloadListTabsAsync() if (isTraktLibrarySourceActive()) { refreshTraktLibraryAsync() } } + if (isSimklLibrarySourceActive()) refreshSimklLibraryAsync() } fun onProfileChanged(profileId: Int) { @@ -159,6 +186,8 @@ object LibraryRepository { refreshTraktLibraryAsync() } } + SimklLibraryRepository.ensureLoaded() + if (isSimklLibrarySourceActive()) refreshSimklLibraryAsync() } fun clearLocalState() { @@ -224,17 +253,32 @@ object LibraryRepository { return } - if (isTraktLibrarySourceActive()) { - try { - TraktLibraryRepository.refreshNow() - } catch (error: CancellationException) { - throw error - } catch (error: Throwable) { - log.e(error) { "Failed to pull Trakt library" } + when (effectiveLibrarySourceMode()) { + LibrarySourceMode.TRAKT -> { + try { + TraktLibraryRepository.refreshNow() + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.e(error) { "Failed to pull Trakt library" } + } + if (!isActiveOperation(operationToken)) return + publish() + return } - if (!isActiveOperation(operationToken)) return - publish() - return + LibrarySourceMode.SIMKL -> { + try { + SimklLibraryRepository.refreshNow() + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.e(error) { "Failed to pull Simkl library" } + } + if (!isActiveOperation(operationToken)) return + publish() + return + } + LibrarySourceMode.LOCAL -> Unit } nuvioPullMutex.withLock { @@ -295,6 +339,29 @@ object LibraryRepository { return } + if (isSimklLibrarySourceActive()) { + val profileId = localState.snapshot().token.profileId + val isCurrentlySaved = SimklLibraryRepository.isInWatchlist(item.id, item.type) + log.i { + "toggleSaved routed to Simkl library source item=${item.id} type=${item.type} profile=$profileId" + } + syncScope.launch { + runCatching { + SimklLibraryRepository.setWatchlistMembership( + profileId = profileId, + item = item, + isMember = !isCurrentlySaved, + ) + }.onFailure { error -> + if (error is CancellationException) throw error + log.e(error) { "Failed to toggle Simkl watchlist" } + NuvioToastController.show(error.message ?: "Unable to update Simkl Watchlist") + } + publish() + } + return + } + val result = localState.toggle( item.copy(savedAtEpochMs = LibraryClock.nowEpochMs()), ) @@ -366,6 +433,10 @@ object LibraryRepository { return false } + if (isSimklLibrarySourceActive()) { + return SimklLibraryRepository.isInWatchlist(id, type) + } + return if (type != null) { localState.contains(id, type) } else { @@ -380,16 +451,25 @@ object LibraryRepository { return TraktLibraryRepository.uiState.value.allItems.firstOrNull { it.id == id } } + if (isSimklLibrarySourceActive()) { + return SimklLibraryRepository.uiState.value.items.firstOrNull { it.id == id } + } + return localState.findById(id) } fun libraryListTabs(): List { - val traktTabs = if (TraktAuthRepository.isAuthenticated.value) { + val traktTabs = if (TrackingProviderRegistry.isAuthenticated(TrackingProviderId.TRAKT)) { TraktLibraryRepository.currentListTabs() } else { emptyList() } - return libraryTabsWithLocal(traktTabs) + val simklTabs = if (TrackingProviderRegistry.isAuthenticated(TrackingProviderId.SIMKL)) { + listOf(simklLibraryListTab()) + } else { + emptyList() + } + return libraryTabsWithLocal(traktTabs + simklTabs) } fun traktListTabs(): List = libraryListTabs() @@ -397,14 +477,14 @@ object LibraryRepository { suspend fun getMembershipSnapshot(item: LibraryItem): Map { ensureLoaded() val inLocal = localState.contains(item.id, item.type) - if (TraktAuthRepository.isAuthenticated.value) { - val traktMembership = TraktLibraryRepository.getMembershipSnapshot(item).listMembership - return libraryMembershipWithLocal( - inLocal = inLocal, - traktMembership = traktMembership, - ) + val memberships = linkedMapOf() + if (TrackingProviderRegistry.isAuthenticated(TrackingProviderId.TRAKT)) { + memberships += TraktLibraryRepository.getMembershipSnapshot(item).listMembership } - return libraryMembershipWithLocal(inLocal = inLocal) + if (TrackingProviderRegistry.isAuthenticated(TrackingProviderId.SIMKL)) { + memberships[SIMKL_WATCHLIST_KEY] = SimklLibraryRepository.isInWatchlist(item.id, item.type) + } + return libraryMembershipWithLocal(inLocal = inLocal, traktMembership = memberships) } suspend fun applyMembershipChanges(item: LibraryItem, desiredMembership: Map) { @@ -415,7 +495,7 @@ object LibraryRepository { log.i { "Applying library membership item=${item.id} type=${item.type} profile=$profileId " + "localDesired=$localDesired currentlyInLocal=$currentlyInLocal " + - "traktAuthenticated=${TraktAuthRepository.isAuthenticated.value}" + "connectedProviders=${TrackingProviderRegistry.connectedProviderIdsSnapshot()}" } if (localDesired != currentlyInLocal) { if (localDesired) { @@ -425,18 +505,44 @@ object LibraryRepository { } } - if (TraktAuthRepository.isAuthenticated.value) { - val traktMembership = desiredMembership.filterKeys { it != LOCAL_LIBRARY_LIST_KEY } + var firstFailure: Throwable? = null + if (TrackingProviderRegistry.isAuthenticated(TrackingProviderId.TRAKT)) { + val traktListKeys = TraktLibraryRepository.currentListTabs().mapTo(mutableSetOf(), TraktListTab::key) + val traktMembership = desiredMembership.filterKeys { key -> key in traktListKeys } if (traktMembership.isNotEmpty()) { - TraktLibraryRepository.applyMembershipChanges( - item = item, - changes = TraktMembershipChanges(desiredMembership = traktMembership), - ) + try { + TraktLibraryRepository.applyMembershipChanges( + item = item, + changes = TraktMembershipChanges(desiredMembership = traktMembership), + ) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + firstFailure = error + log.e(error) { "Failed to update Trakt library membership" } + } } - publish() - } else { - publish() } + if (TrackingProviderRegistry.isAuthenticated(TrackingProviderId.SIMKL)) { + val desired = desiredMembership[SIMKL_WATCHLIST_KEY] == true + val current = SimklLibraryRepository.isInWatchlist(item.id, item.type) + if (desired != current) { + try { + SimklLibraryRepository.setWatchlistMembership( + profileId = profileId, + item = item, + isMember = desired, + ) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + if (firstFailure == null) firstFailure = error + log.e(error) { "Failed to update Simkl library membership" } + } + } + } + publish() + firstFailure?.let { throw it } } suspend fun removeFromList(item: LibraryItem, listKey: String) { @@ -528,33 +634,59 @@ object LibraryRepository { private fun publish() { val localSnapshot = localState.snapshot() - if (isTraktLibrarySourceActive()) { - val traktState = TraktLibraryRepository.uiState.value - val sections = traktState.listTabs.mapNotNull { tab -> - val listItems = traktState.entriesByList[tab.key].orEmpty() - if (listItems.isEmpty()) { - null - } else { - LibrarySection( - type = tab.key, - displayTitle = tab.title, - items = listItems, - ) + when (effectiveLibrarySourceMode()) { + LibrarySourceMode.TRAKT -> { + val traktState = TraktLibraryRepository.uiState.value + val sections = traktState.listTabs.mapNotNull { tab -> + val listItems = traktState.entriesByList[tab.key].orEmpty() + if (listItems.isEmpty()) { + null + } else { + LibrarySection( + type = tab.key, + displayTitle = tab.title, + items = listItems, + ) + } } - } - val newUiState = LibraryUiState( - sourceMode = LibrarySourceMode.TRAKT, - items = traktState.allItems, - sections = sections, - isLoaded = traktState.hasLoaded, - isLoading = traktState.isLoading, - errorMessage = traktState.errorMessage, - ) - localState.runIfTokenCurrent(localSnapshot.token) { - _uiState.value = newUiState + val newUiState = LibraryUiState( + sourceMode = LibrarySourceMode.TRAKT, + items = traktState.allItems, + sections = sections, + isLoaded = traktState.hasLoaded, + isLoading = traktState.isLoading, + errorMessage = traktState.errorMessage, + ) + localState.runIfTokenCurrent(localSnapshot.token) { + _uiState.value = newUiState + } + return } - return + LibrarySourceMode.SIMKL -> { + val simklState = SimklLibraryRepository.uiState.value + val items = simklState.items.sortedByDescending(LibraryItem::savedAtEpochMs) + val sections = listOf( + LibrarySection( + type = SIMKL_WATCHLIST_KEY, + displayTitle = SIMKL_WATCHLIST_TITLE, + items = items, + ), + ) + val newUiState = LibraryUiState( + sourceMode = LibrarySourceMode.SIMKL, + items = items, + sections = sections, + isLoaded = simklState.hasLoaded, + isLoading = simklState.isLoading, + errorMessage = simklState.errorMessage, + ) + localState.runIfTokenCurrent(localSnapshot.token) { + _uiState.value = newUiState + } + return + } + LibrarySourceMode.LOCAL -> Unit } val items = localSnapshot.items @@ -608,6 +740,17 @@ object LibraryRepository { } } + private fun refreshSimklLibraryAsync() { + syncScope.launch { + runCatching { SimklLibraryRepository.refreshNow() } + .onFailure { error -> + if (error is CancellationException) throw error + log.e(error) { "Failed to refresh Simkl library" } + } + publish() + } + } + private fun selectedLibrarySourceMode(): LibrarySourceMode { TraktSettingsRepository.ensureLoaded() return TraktSettingsRepository.uiState.value.librarySourceMode @@ -615,12 +758,15 @@ object LibraryRepository { private fun effectiveLibrarySourceMode(): LibrarySourceMode = resolveEffectiveLibrarySourceMode( - isAuthenticated = TraktAuthRepository.isAuthenticated.value, - source = selectedLibrarySourceMode(), + requestedSource = selectedLibrarySourceMode(), + isProviderAuthenticated = TrackingProviderRegistry::isAuthenticated, ) private fun isTraktLibrarySourceActive(): Boolean = effectiveLibrarySourceMode() == LibrarySourceMode.TRAKT + + private fun isSimklLibrarySourceActive(): Boolean = + effectiveLibrarySourceMode() == LibrarySourceMode.SIMKL } internal const val LOCAL_LIBRARY_LIST_KEY = "local" @@ -637,6 +783,13 @@ internal fun localLibraryListTab(): TraktListTab = type = TraktListType.WATCHLIST, ) +internal fun simklLibraryListTab(): TraktListTab = + TraktListTab( + key = SIMKL_WATCHLIST_KEY, + title = SIMKL_WATCHLIST_TITLE, + type = TraktListType.WATCHLIST, + ) + internal fun libraryTabsWithLocal(traktTabs: List): List = listOf(localLibraryListTab()) + traktTabs diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt new file mode 100644 index 00000000..b327b8b3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt @@ -0,0 +1,236 @@ +package com.nuvio.app.features.simkl + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.library.LibraryItem +import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.tracking.TrackingHistoryItem +import com.nuvio.app.features.tracking.TrackingListStatus +import com.nuvio.app.features.watched.WatchedItem +import com.nuvio.app.features.watching.sync.WatchedSyncAdapter +import com.nuvio.app.features.watchprogress.WatchProgressEntry +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.flow.collectLatest +import kotlinx.coroutines.launch + +data class SimklLibraryUiState( + val items: List = emptyList(), + val isLoading: Boolean = false, + val hasLoaded: Boolean = false, + val errorMessage: String? = null, +) + +object SimklLibraryRepository { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val _uiState = MutableStateFlow(SimklLibraryUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + scope.launch { + SimklSyncRepository.state.collectLatest(::publish) + } + } + + fun ensureLoaded() { + SimklAuthRepository.ensureLoaded() + SimklSyncRepository.ensureLoaded() + publish(SimklSyncRepository.state.value) + } + + suspend fun refreshNow() { + SimklSyncRepository.refreshNow() + publish(SimklSyncRepository.state.value) + } + + fun isInWatchlist(contentId: String, contentType: String? = null): Boolean { + ensureLoaded() + return uiState.value.items.any { item -> + item.id == contentId && (contentType == null || item.type.equals(contentType, ignoreCase = true)) + } + } + + suspend fun setWatchlistMembership( + profileId: Int, + item: LibraryItem, + isMember: Boolean, + ) { + if (profileId != ProfileRepository.activeProfileId) return + ensureLoaded() + val snapshot = SimklSyncRepository.state.value.snapshot + val media = snapshot.mediaReference( + contentId = item.id, + contentType = item.type, + title = item.name, + releaseInfo = item.releaseInfo, + ) + val result = if (isMember) { + SimklMutationRepository.moveToList( + profileId = profileId, + items = listOf(media), + destination = TrackingListStatus.PLAN_TO_WATCH, + ) + } else { + require(snapshot.canSafelyRemoveFromSimklWatchlist(item.id)) { + "Removing this item from Simkl would also clear watched history or a rating" + } + SimklMutationRepository.removeFromList(profileId = profileId, items = listOf(media)) + } + check(result.isComplete) { + "Simkl could not match ${result.notFoundCount} of ${result.attemptedCount} library items" + } + refreshNow() + } + + private fun publish(syncState: SimklSyncUiState) { + _uiState.value = SimklLibraryUiState( + items = syncState.snapshot.toSimklLibraryItems(), + isLoading = syncState.isLoading, + hasLoaded = syncState.hasLoaded, + errorMessage = syncState.errorMessage, + ) + } +} + +object SimklWatchedSyncAdapter : WatchedSyncAdapter { + override suspend fun pull(profileId: Int, pageSize: Int): List { + if (profileId != ProfileRepository.activeProfileId) return emptyList() + SimklSyncRepository.ensureFresh() + return SimklSyncRepository.state.value.snapshot.toSimklWatchedProjection().items + } + + override suspend fun pullFullyWatchedSeriesKeys(profileId: Int): Set? { + if (profileId != ProfileRepository.activeProfileId) return null + SimklSyncRepository.ensureFresh() + return SimklSyncRepository.state.value.snapshot.toSimklWatchedProjection().fullyWatchedSeriesKeys + } + + override suspend fun push(profileId: Int, items: Collection) { + if (profileId != ProfileRepository.activeProfileId || items.isEmpty()) return + SimklSyncRepository.ensureLoaded() + val snapshot = SimklSyncRepository.state.value.snapshot + val historyItems = items.map { item -> + TrackingHistoryItem( + media = snapshot.mediaReference( + contentId = item.id, + contentType = item.type, + title = item.name, + releaseInfo = item.releaseInfo, + season = item.season, + episode = item.episode, + ), + watchedAtEpochMs = item.markedAtEpochMs, + ) + } + val result = SimklMutationRepository.addToHistory(profileId = profileId, items = historyItems) + check(result.isComplete) { + "Simkl could not match ${result.notFoundCount} of ${result.attemptedCount} watched items" + } + } + + override suspend fun delete(profileId: Int, items: Collection) { + if (profileId != ProfileRepository.activeProfileId || items.isEmpty()) return + SimklSyncRepository.ensureLoaded() + val snapshot = SimklSyncRepository.state.value.snapshot + val media = items.map { item -> + snapshot.mediaReference( + contentId = item.id, + contentType = item.type, + title = item.name, + releaseInfo = item.releaseInfo, + season = item.season, + episode = item.episode, + ) + } + val result = SimklMutationRepository.removeFromHistory(profileId = profileId, items = media) + check(result.isComplete) { + "Simkl could not match ${result.notFoundCount} of ${result.attemptedCount} watched items" + } + } +} + +data class SimklProgressUiState( + val entries: List = emptyList(), + val isLoading: Boolean = false, + val hasLoadedRemoteProgress: Boolean = false, + val errorMessage: String? = null, +) + +object SimklProgressRepository { + private val log = Logger.withTag("SimklProgress") + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val _uiState = MutableStateFlow(SimklProgressUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + scope.launch { + SimklSyncRepository.state.collectLatest(::publish) + } + } + + fun ensureLoaded() { + SimklAuthRepository.ensureLoaded() + SimklSyncRepository.ensureLoaded() + publish(SimklSyncRepository.state.value) + } + + suspend fun refreshNow() { + SimklSyncRepository.refreshNow() + publish(SimklSyncRepository.state.value) + } + + suspend fun removeProgress(entries: Collection) { + val sessionIds = entries.mapNotNullTo(linkedSetOf()) { entry -> + entry.progressKey + ?.removePrefix(SIMKL_PLAYBACK_PROGRESS_KEY_PREFIX) + ?.takeIf { entry.progressKey.startsWith(SIMKL_PLAYBACK_PROGRESS_KEY_PREFIX) } + ?.toLongOrNull() + ?.takeIf { it > 0L } + } + if (sessionIds.isEmpty()) return + + val removed = linkedSetOf() + for (sessionId in sessionIds) { + try { + SimklApi.client.execute( + SimklApiRequest( + method = SimklHttpMethod.DELETE, + path = "/sync/playback/$sessionId", + ), + ) + removed += sessionId + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.w { "Failed to remove Simkl playback $sessionId: ${error.message}" } + } + } + SimklSyncRepository.commitPlaybackRemoval(removed) + if (removed.isNotEmpty()) SimklSyncRepository.refreshAsync() + } + + private fun publish(syncState: SimklSyncUiState) { + _uiState.value = SimklProgressUiState( + entries = syncState.snapshot.toSimklProgressEntries(), + isLoading = syncState.isLoading, + hasLoadedRemoteProgress = syncState.hasLoaded && syncState.errorMessage == null, + errorMessage = syncState.errorMessage, + ) + } +} + +private fun SimklSyncSnapshot.canSafelyRemoveFromSimklWatchlist(contentId: String): Boolean = + entries.firstOrNull { entry -> entry.media?.canonicalContentId().equals(contentId, ignoreCase = true) } + ?.let { entry -> + entry.status == SimklListStatus.PLAN_TO_WATCH && + entry.lastWatchedAt == null && + entry.userRating == null && + entry.seasons.none { season -> season.episodes.any { episode -> episode.watchedAt != null } } + } + ?: true + +private const val SIMKL_PLAYBACK_PROGRESS_KEY_PREFIX = "simkl-playback:" 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 index 026791e1..184dc1c6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt @@ -105,6 +105,17 @@ object SimklSyncRepository : TrackingProfileStore { } } + internal fun commitPlaybackRemoval(sessionIds: Set) { + if (sessionIds.isEmpty()) return + ensureLoaded() + val current = _state.value + val updatedPlayback = current.snapshot.playback.filterNot { session -> session.id in sessionIds } + if (updatedPlayback.size == current.snapshot.playback.size) return + val updatedSnapshot = current.snapshot.copy(playback = updatedPlayback) + SimklSyncStorage.savePayload(json.encodeToString(updatedSnapshot)) + _state.value = current.copy(snapshot = updatedSnapshot) + } + override fun onProfileChanged() { profileGeneration += 1L hasLoaded = false diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt index 7c05b9a1..2bd43d27 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt @@ -3,13 +3,16 @@ package com.nuvio.app.features.watched import co.touchlab.kermit.Logger import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState +import com.nuvio.app.core.tracking.ensureTrackingProvidersRegistered import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.profiles.ProfileRepository -import com.nuvio.app.features.trakt.TraktAuthRepository -import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.simkl.SimklWatchedSyncAdapter +import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.TrackingProviderRegistry import com.nuvio.app.features.tracking.WatchProgressSource -import com.nuvio.app.features.trakt.shouldUseTraktProgress +import com.nuvio.app.features.tracking.effectiveWatchProgressSource +import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.watching.sync.SupabaseWatchedSyncAdapter import com.nuvio.app.features.watching.sync.TraktWatchedSyncAdapter import com.nuvio.app.features.watching.sync.WatchedDeltaEvent @@ -140,16 +143,18 @@ object WatchedRepository { private var deltaInitialized: Boolean = false internal var syncAdapter: WatchedSyncAdapter = SupabaseWatchedSyncAdapter internal var traktSyncAdapter: WatchedSyncAdapter = TraktWatchedSyncAdapter + internal var simklSyncAdapter: WatchedSyncAdapter = SimklWatchedSyncAdapter fun ensureLoaded() { - TraktAuthRepository.ensureLoaded() + ensureTrackingProvidersRegistered() + TrackingProviderRegistry.ensureLoaded() TraktSettingsRepository.ensureLoaded() if (!hasLoaded) { loadFromDisk(ProfileRepository.activeProfileId) activateEffectiveSource( effectiveWatchedSource( requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource, - isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value, + connectedProviderIds = TrackingProviderRegistry.connectedProviderIdsSnapshot(), ), ) } @@ -293,26 +298,26 @@ object WatchedRepository { ) suspend fun pullFromServer(profileId: Int) { - TraktAuthRepository.ensureLoaded() + TrackingProviderRegistry.ensureLoaded() TraktSettingsRepository.ensureLoaded() refreshForSource( profileId = profileId, source = effectiveWatchedSource( requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource, - isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value, + connectedProviderIds = TrackingProviderRegistry.connectedProviderIdsSnapshot(), ), forceSnapshot = false, ) } suspend fun forceSnapshotRefreshFromServer(profileId: Int) { - TraktAuthRepository.ensureLoaded() + TrackingProviderRegistry.ensureLoaded() TraktSettingsRepository.ensureLoaded() refreshForSource( profileId = profileId, source = effectiveWatchedSource( requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource, - isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value, + connectedProviderIds = TrackingProviderRegistry.connectedProviderIdsSnapshot(), ), forceSnapshot = true, ) @@ -323,7 +328,7 @@ object WatchedRepository { source: WatchProgressSource, forceSnapshot: Boolean = true, ): Boolean { - TraktAuthRepository.ensureLoaded() + TrackingProviderRegistry.ensureLoaded() TraktSettingsRepository.ensureLoaded() if (ProfileRepository.activeProfileId != profileId) { log.d { "Skipping watched refresh for inactive profile $profileId" } @@ -353,7 +358,12 @@ object WatchedRepository { profileId = profileId, resetDeltaState = true, ) - WatchProgressSource.SIMKL -> false + WatchProgressSource.SIMKL -> pullSnapshotFromAdapter( + adapter = simklSyncAdapter, + operation = operation, + profileId = profileId, + resetDeltaState = true, + ) WatchProgressSource.NUVIO_SYNC -> if (forceSnapshot) { refreshNuvioSnapshot( operation = operation, @@ -412,6 +422,7 @@ object WatchedRepository { profileId = profileId, pageSize = watchedItemsPageSize, ) + val fullyWatchedSeriesKeys = adapter.pullFullyWatchedSeriesKeys(profileId) if (!isActiveOperation(operation)) return false val localAtApply = itemsForSource(operation.sourceOperation.source).values.toList() @@ -431,6 +442,9 @@ object WatchedRepository { simklItems = simklItemsByKey, replacement = mergedSnapshot.items, ) + fullyWatchedSeriesKeys?.let { keys -> + setFullyWatchedSeriesKeysForSource(operation.sourceOperation.source, keys) + } when (operation.sourceOperation.source) { WatchProgressSource.NUVIO_SYNC -> { nuvioDirtyWatchedKeys = mergedSnapshot.dirtyKeys.toMutableSet() @@ -977,22 +991,31 @@ object WatchedRepository { traktHistorySync: WatchedTraktHistorySync, source: WatchProgressSource, ): Boolean { - val shouldMirrorToTrakt = shouldMirrorWatchedMarkToTraktHistory( - sync = traktHistorySync, - isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value, - ) - - if (source == WatchProgressSource.TRAKT) { - if (!shouldMirrorToTrakt) return false - traktSyncAdapter.push(profileId = profileId, items = items) - return true + var anySucceeded = false + if (source == WatchProgressSource.NUVIO_SYNC) { + try { + syncAdapter.push(profileId = profileId, items = items) + anySucceeded = true + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.e(error) { "Failed to push watched items to Nuvio Sync" } + } } - syncAdapter.push(profileId = profileId, items = items) - if (shouldMirrorToTrakt) { - traktSyncAdapter.push(profileId = profileId, items = items) + if (traktHistorySync == WatchedTraktHistorySync.Mirror) { + connectedTrackerSyncAdapters().forEach { (providerId, adapter) -> + try { + adapter.push(profileId = profileId, items = items) + anySucceeded = true + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.e(error) { "Failed to push watched items to ${providerId.storageId}" } + } + } } - return true + return anySucceeded } private suspend fun deleteFromTargetsForSource( @@ -1000,17 +1023,33 @@ object WatchedRepository { items: Collection, source: WatchProgressSource, ) { - if (source == WatchProgressSource.TRAKT) { - traktSyncAdapter.delete(profileId = profileId, items = items) - return + if (source == WatchProgressSource.NUVIO_SYNC) { + try { + syncAdapter.delete(profileId = profileId, items = items) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.e(error) { "Failed to delete watched items from Nuvio Sync" } + } } - syncAdapter.delete(profileId = profileId, items = items) - if (TraktAuthRepository.isAuthenticated.value) { - traktSyncAdapter.delete(profileId = profileId, items = items) + connectedTrackerSyncAdapters().forEach { (providerId, adapter) -> + try { + adapter.delete(profileId = profileId, items = items) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.e(error) { "Failed to delete watched items from ${providerId.storageId}" } + } } } + private fun connectedTrackerSyncAdapters(): List> = + listOf( + TrackingProviderId.TRAKT to traktSyncAdapter, + TrackingProviderId.SIMKL to simklSyncAdapter, + ).filter { (providerId, _) -> TrackingProviderRegistry.isAuthenticated(providerId) } + private fun accountScopeSnapshot(): CoroutineScope = synchronized(accountScopeLock) { accountScope @@ -1079,20 +1118,23 @@ internal fun acknowledgeSuccessfulWatchedPush( internal fun shouldUseTraktWatchedSync( isAuthenticated: Boolean, source: WatchProgressSource, -): Boolean = shouldUseTraktProgress( - isAuthenticated = isAuthenticated, - source = source, -) +): Boolean = isAuthenticated && source == WatchProgressSource.TRAKT internal fun effectiveWatchedSource( requestedSource: WatchProgressSource, isTraktAuthenticated: Boolean, -): WatchProgressSource = - if (shouldUseTraktWatchedSync(isAuthenticated = isTraktAuthenticated, source = requestedSource)) { - WatchProgressSource.TRAKT - } else { - WatchProgressSource.NUVIO_SYNC - } +): WatchProgressSource = effectiveWatchedSource( + requestedSource = requestedSource, + connectedProviderIds = if (isTraktAuthenticated) setOf(TrackingProviderId.TRAKT) else emptySet(), +) + +internal fun effectiveWatchedSource( + requestedSource: WatchProgressSource, + connectedProviderIds: Set, +): WatchProgressSource = effectiveWatchProgressSource( + requestedSource = requestedSource, + isProviderAuthenticated = { providerId -> providerId in connectedProviderIds }, +) private fun String.isSeriesLikeWatchedType(): Boolean = trim().lowercase() in setOf("series", "show", "tv", "tvshow") diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/WatchedSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/WatchedSyncAdapter.kt index 1e77f43f..7ad50c42 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/WatchedSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/WatchedSyncAdapter.kt @@ -19,6 +19,8 @@ interface WatchedSyncAdapter { pageSize: Int, ): List + suspend fun pullFullyWatchedSeriesKeys(profileId: Int): Set? = null + suspend fun getDeltaCursor(profileId: Int): Long? = null suspend fun pullDelta( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index b7bbfb68..94b59127 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -3,6 +3,7 @@ package com.nuvio.app.features.watchprogress import co.touchlab.kermit.Logger import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState +import com.nuvio.app.core.tracking.ensureTrackingProvidersRegistered import com.nuvio.app.features.addons.AddonManifest import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonsUiState @@ -11,11 +12,14 @@ import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.player.PlayerPlaybackSnapshot import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.simkl.SimklProgressRepository +import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.TrackingProviderRegistry +import com.nuvio.app.features.tracking.WatchProgressSource +import com.nuvio.app.features.tracking.effectiveWatchProgressSource import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktProgressRepository import com.nuvio.app.features.trakt.TraktSettingsRepository -import com.nuvio.app.features.tracking.WatchProgressSource -import com.nuvio.app.features.trakt.effectiveWatchProgressSource import com.nuvio.app.features.trakt.isTraktCompatibleId import com.nuvio.app.features.trakt.resolveEffectiveContentId import com.nuvio.app.features.watching.application.WatchingActions @@ -67,6 +71,34 @@ private data class MetadataProviderReadiness( get() = providers.isNotEmpty() } +internal fun mergeTrackerProgressEntries( + remoteEntries: Collection, + localEntries: Collection, +): List { + val newestByMedia = linkedMapOf() + remoteEntries.forEach { entry -> + newestByMedia[entry.trackerMediaIdentity()] = entry + } + localEntries.forEach { entry -> + val key = entry.trackerMediaIdentity() + val existing = newestByMedia[key] + if (existing == null || entry.lastUpdatedEpochMs > existing.lastUpdatedEpochMs) { + newestByMedia[key] = entry + } + } + return newestByMedia.values.toList() +} + +private fun WatchProgressEntry.trackerMediaIdentity(): String = buildString { + append(parentMetaType.trim().lowercase()) + append(':') + append(parentMetaId.trim().lowercase()) + append(':') + append(seasonNumber ?: -1) + append(':') + append(episodeNumber ?: -1) +} + internal class MetadataResolutionRetryCoordinator { private val lock = SynchronizedObject() private var generation = 0L @@ -243,6 +275,14 @@ object WatchProgressRepository { } } + syncScope.launch { + SimklProgressRepository.uiState.collectLatest { + if (activeSource == WatchProgressSource.SIMKL) { + publish() + } + } + } + syncScope.launch { AddonRepository.uiState.collectLatest { state -> retryMetadataResolutionWhenAddonMetaProvidersReady(state) @@ -252,14 +292,16 @@ object WatchProgressRepository { } fun ensureLoaded() { - TraktAuthRepository.ensureLoaded() + ensureTrackingProvidersRegistered() + TrackingProviderRegistry.ensureLoaded() TraktSettingsRepository.ensureLoaded() TraktProgressRepository.ensureLoaded() + SimklProgressRepository.ensureLoaded() if (!hasLoaded) { updateActiveSource( effectiveWatchProgressSource( - isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value, requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource, + isProviderAuthenticated = TrackingProviderRegistry::isAuthenticated, ), ) loadFromDisk(ProfileRepository.activeProfileId) @@ -271,8 +313,8 @@ object WatchProgressRepository { TraktSettingsRepository.onProfileChanged() updateActiveSource( effectiveWatchProgressSource( - isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value, requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource, + isProviderAuthenticated = TrackingProviderRegistry::isAuthenticated, ), ) loadFromDisk(profileId) @@ -372,9 +414,10 @@ object WatchProgressRepository { } internal fun activateSource(source: WatchProgressSource) { - TraktAuthRepository.ensureLoaded() + TrackingProviderRegistry.ensureLoaded() TraktSettingsRepository.ensureLoaded() TraktProgressRepository.ensureLoaded() + SimklProgressRepository.ensureLoaded() if (!hasLoaded) { loadFromDisk(ProfileRepository.activeProfileId) } @@ -385,10 +428,10 @@ object WatchProgressRepository { updateActiveSource(source) cancelMetadataResolution(resetProviderHistory = false) - if (source == WatchProgressSource.TRAKT) { - TraktProgressRepository.clearLocalState() - } else { - hasLoadedNuvioRemoteProgress = false + when (source) { + WatchProgressSource.TRAKT -> TraktProgressRepository.clearLocalState() + WatchProgressSource.NUVIO_SYNC -> hasLoadedNuvioRemoteProgress = false + WatchProgressSource.SIMKL -> Unit } publish() if (source == WatchProgressSource.NUVIO_SYNC) { @@ -426,7 +469,33 @@ object WatchProgressRepository { force = force, ) - WatchProgressSource.SIMKL -> false + WatchProgressSource.SIMKL -> refreshSimklSource( + profileId = profileId, + operationGeneration = operationGeneration, + ) + } + } + + private suspend fun refreshSimklSource( + profileId: Int, + operationGeneration: Long, + ): Boolean { + if (!TrackingProviderRegistry.isAuthenticated(TrackingProviderId.SIMKL)) { + log.d { "Skipping Simkl progress refresh because Simkl is not authenticated" } + return false + } + return try { + SimklProgressRepository.refreshNow() + if (isActiveOperation(profileId, operationGeneration) && activeSource == WatchProgressSource.SIMKL) { + publish() + } + val state = SimklProgressRepository.uiState.value + state.hasLoadedRemoteProgress && state.errorMessage == null + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.e(error) { "Failed to refresh Simkl watch progress" } + false } } @@ -1050,6 +1119,21 @@ object WatchProgressRepository { if (videoIds.isEmpty()) return val useTraktProgress = shouldUseTraktProgress() + if (shouldUseSimklProgress()) { + val entriesToRemove = currentEntries().filter { entry -> + entry.videoId in videoIds && + (parentMetaId == null || entry.parentMetaId == parentMetaId) + } + val locallyRemovedEntries = removeStoredLocalEntries(entriesToRemove) + if (locallyRemovedEntries.isNotEmpty()) persist() + publish() + if (entriesToRemove.isNotEmpty()) { + syncScope.launch { + SimklProgressRepository.removeProgress(entriesToRemove) + } + } + return + } if (useTraktProgress) { val entriesToRemove = currentEntries().filter { entry -> entry.videoId in videoIds && @@ -1127,6 +1211,16 @@ object WatchProgressRepository { } if (entriesToRemove.isEmpty()) return + if (shouldUseSimklProgress()) { + val locallyRemovedEntries = removeStoredLocalEntries(entriesToRemove) + if (locallyRemovedEntries.isNotEmpty()) persist() + publish() + syncScope.launch { + SimklProgressRepository.removeProgress(entriesToRemove) + } + return + } + if (useTraktProgress) { val locallyRemovedEntries = removeStoredLocalEntries(entriesToRemove) TraktProgressRepository.applyOptimisticRemoval( @@ -1172,11 +1266,7 @@ object WatchProgressRepository { episodeNumber: Int? = null, ): WatchProgressEntry? { ensureLoaded() - return if (shouldUseTraktProgress()) { - TraktProgressRepository.uiState.value.entries - } else { - localEntriesSnapshot() - }.resolveProgressForVideo( + return currentEntries().resolveProgressForVideo( videoId = videoId, parentMetaId = parentMetaId, seasonNumber = seasonNumber, @@ -1357,7 +1447,7 @@ object WatchProgressRepository { } private fun pushDeleteToServer(entries: Collection) { - if (shouldUseTraktProgress()) return + if (activeSource != WatchProgressSource.NUVIO_SYNC) return val profileId = currentProfileId accountScopeSnapshot().launch { runCatching { @@ -1372,10 +1462,10 @@ object WatchProgressRepository { private fun publish() { val entries = currentEntries() val sortedEntries = entries.sortedByDescending { it.lastUpdatedEpochMs } - val hasLoadedRemoteProgress = if (shouldUseTraktProgress()) { - TraktProgressRepository.uiState.value.hasLoadedRemoteProgress - } else { - hasLoadedNuvioRemoteProgress + val hasLoadedRemoteProgress = when (activeSource) { + WatchProgressSource.TRAKT -> TraktProgressRepository.uiState.value.hasLoadedRemoteProgress + WatchProgressSource.SIMKL -> SimklProgressRepository.uiState.value.hasLoadedRemoteProgress + WatchProgressSource.NUVIO_SYNC -> hasLoadedNuvioRemoteProgress } _uiState.value = WatchProgressUiState( entries = sortedEntries, @@ -1471,6 +1561,9 @@ object WatchProgressRepository { private fun shouldUseTraktProgress(): Boolean = activeSource == WatchProgressSource.TRAKT + private fun shouldUseSimklProgress(): Boolean = + activeSource == WatchProgressSource.SIMKL + private fun accountScopeSnapshot(): CoroutineScope = synchronized(accountScopeLock) { accountScope } @@ -1501,28 +1594,33 @@ object WatchProgressRepository { } private fun currentEntries(): List { - return if (shouldUseTraktProgress()) { - // Merge Trakt remote progress with local-only entries that use - // non-Trakt-compatible IDs (kitsu:, mal:, anilist:, etc.). - // Trakt will never return these IDs, so they must come from local storage. - val traktItems = TraktProgressRepository.uiState.value.entries - val localNonTraktItems = localEntriesSnapshot().filter { - !isTraktCompatibleId(it.parentMetaId) - } - if (localNonTraktItems.isEmpty()) { - traktItems - } else { - val traktKeys = traktItems.mapTo(mutableSetOf()) { entry -> entry.resolvedProgressKey() } - val merged = traktItems.toMutableList() - localNonTraktItems.forEach { localItem -> - if (localItem.resolvedProgressKey() !in traktKeys) { - merged.add(localItem) - } + return when (activeSource) { + WatchProgressSource.TRAKT -> { + // Merge Trakt remote progress with local-only entries that use + // non-Trakt-compatible IDs (kitsu:, mal:, anilist:, etc.). + // Trakt will never return these IDs, so they must come from local storage. + val traktItems = TraktProgressRepository.uiState.value.entries + val localNonTraktItems = localEntriesSnapshot().filter { + !isTraktCompatibleId(it.parentMetaId) + } + if (localNonTraktItems.isEmpty()) { + traktItems + } else { + val traktKeys = traktItems.mapTo(mutableSetOf()) { entry -> entry.resolvedProgressKey() } + val merged = traktItems.toMutableList() + localNonTraktItems.forEach { localItem -> + if (localItem.resolvedProgressKey() !in traktKeys) { + merged.add(localItem) + } + } + merged } - merged } - } else { - localEntriesSnapshot() + WatchProgressSource.SIMKL -> mergeTrackerProgressEntries( + remoteEntries = SimklProgressRepository.uiState.value.entries, + localEntries = localEntriesSnapshot(), + ) + WatchProgressSource.NUVIO_SYNC -> localEntriesSnapshot() } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt index 6668e3e9..8a83ff41 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt @@ -3,12 +3,14 @@ package com.nuvio.app.features.watchprogress import co.touchlab.kermit.Logger import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState +import com.nuvio.app.core.tracking.ensureTrackingProvidersRegistered import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.tracking.DEFAULT_WATCH_PROGRESS_SOURCE -import com.nuvio.app.features.trakt.TraktAuthRepository -import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.TrackingProviderRegistry import com.nuvio.app.features.tracking.WatchProgressSource -import com.nuvio.app.features.trakt.effectiveWatchProgressSource +import com.nuvio.app.features.tracking.effectiveWatchProgressSource +import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.watched.WatchedRepository import kotlinx.atomicfu.atomic import kotlinx.atomicfu.locks.SynchronizedObject @@ -234,15 +236,15 @@ object WatchProgressSourceCoordinator { observeJob = scope.launch { combine( TraktSettingsRepository.uiState, - TraktAuthRepository.isAuthenticated, + TrackingProviderRegistry.connectedProviderIds, AuthRepository.state, ProfileRepository.state, - ) { settings, isTraktAuthenticated, authState, profileState -> + ) { settings, connectedProviderIds, authState, profileState -> buildContext( profileId = profileState.activeProfile?.profileIndex ?: ProfileRepository.activeProfileId, requestedSource = settings.watchProgressSource, - isTraktAuthenticated = isTraktAuthenticated, + connectedProviderIds = connectedProviderIds, authState = authState, ) } @@ -260,7 +262,8 @@ object WatchProgressSourceCoordinator { } private fun ensureSourceStateLoaded() { - TraktAuthRepository.ensureLoaded() + ensureTrackingProvidersRegistered() + TrackingProviderRegistry.ensureLoaded() TraktSettingsRepository.ensureLoaded() } @@ -464,14 +467,14 @@ object WatchProgressSourceCoordinator { private fun buildContext( profileId: Int, requestedSource: WatchProgressSource, - isTraktAuthenticated: Boolean, + connectedProviderIds: Set, authState: AuthState, ): WatchProgressSourceContext = WatchProgressSourceContext( profileId = profileId, requestedSource = requestedSource, effectiveSource = effectiveWatchProgressSource( - isTraktAuthenticated = isTraktAuthenticated, requestedSource = requestedSource, + isProviderAuthenticated = { providerId -> providerId in connectedProviderIds }, ), isNuvioAuthenticated = authState is AuthState.Authenticated && !authState.isAnonymous, ) @@ -479,7 +482,7 @@ object WatchProgressSourceCoordinator { private fun currentContext(profileId: Int): WatchProgressSourceContext = buildContext( profileId = profileId, requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource, - isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value, + connectedProviderIds = TrackingProviderRegistry.connectedProviderIdsSnapshot(), authState = AuthRepository.state.value, ) } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt index cd27d137..cea8d6ff 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt @@ -238,6 +238,24 @@ class WatchedRepositoryTest { ) } + @Test + fun effectiveWatchedSource_selectsConnectedSimkl() { + assertEquals( + WatchProgressSource.SIMKL, + effectiveWatchedSource( + requestedSource = WatchProgressSource.SIMKL, + connectedProviderIds = setOf(com.nuvio.app.features.tracking.TrackingProviderId.SIMKL), + ), + ) + assertEquals( + WatchProgressSource.NUVIO_SYNC, + effectiveWatchedSource( + requestedSource = WatchProgressSource.SIMKL, + connectedProviderIds = emptySet(), + ), + ) + } + @Test fun sourceOperationGuard_rejectsResultFromSourceActiveBeforeSwitch() { val traktOperation = WatchedSourceOperation( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt index 39003816..a75017e7 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt @@ -9,6 +9,36 @@ import kotlin.test.assertTrue class WatchProgressIdentityTest { + @Test + fun `tracker progress merge keeps one entry per media and favors fresher local playback`() { + val remote = entry( + progressKey = "simkl-playback:42", + lastUpdatedEpochMs = 100L, + lastPositionMs = 400L, + ) + val staleLocal = entry(lastUpdatedEpochMs = 90L, lastPositionMs = 300L) + val otherLocal = entry( + parentMetaId = "other", + videoId = "other:1:2", + lastUpdatedEpochMs = 80L, + ) + + val firstMerge = mergeTrackerProgressEntries( + remoteEntries = listOf(remote), + localEntries = listOf(staleLocal, otherLocal), + ) + + assertEquals(2, firstMerge.size) + assertEquals("simkl-playback:42", firstMerge.single { it.parentMetaId == "show" }.progressKey) + + val freshLocal = staleLocal.copy(lastUpdatedEpochMs = 110L, lastPositionMs = 500L) + val secondMerge = mergeTrackerProgressEntries( + remoteEntries = listOf(remote), + localEntries = listOf(freshLocal), + ) + assertEquals(500L, secondMerge.single().lastPositionMs) + } + @Test fun `provider change during metadata batch schedules one follow up`() { val coordinator = MetadataResolutionRetryCoordinator() From 2dffff3520bb82b611d4667eb0f52aa113749b71 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:59:16 +0530 Subject: [PATCH 08/59] feat(tracking): fan out real player scrobbles --- .../commonMain/kotlin/com/nuvio/app/App.kt | 21 ++-- .../tracking/TrackingProviderBootstrap.kt | 2 + .../player/PlayerScreenRuntimeEffects.kt | 15 +-- .../PlayerScreenRuntimePlaybackActions.kt | 99 +++++++++++-------- .../player/PlayerScreenRuntimeState.kt | 5 +- .../features/simkl/SimklMutationRepository.kt | 8 +- .../app/features/simkl/SimklProjections.kt | 28 ++++++ .../app/features/tracking/TrackingMedia.kt | 27 +++++ .../tracking/TrackingScrobbleCoordinator.kt | 58 +++++++++++ .../features/trakt/TraktScrobbleRepository.kt | 72 +++++++++++++- .../features/tracking/TrackingMediaTest.kt | 20 ++++ .../TrackingScrobbleCoordinatorTest.kt | 47 +++++++++ 12 files changed, 338 insertions(+), 64 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinator.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinatorTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index dd8cc3e7..dd53197a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -220,9 +220,11 @@ import com.nuvio.app.features.streams.StreamsRepository import com.nuvio.app.features.streams.StreamsScreen import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.player.PlayerSettingsRepository -import com.nuvio.app.features.trakt.TraktAuthRepository +import com.nuvio.app.features.tracking.TrackingScrobbleAction +import com.nuvio.app.features.tracking.TrackingScrobbleCoordinator +import com.nuvio.app.features.tracking.TrackingScrobbleEvent +import com.nuvio.app.features.tracking.buildTrackingMediaReference import com.nuvio.app.features.trakt.TraktListTab -import com.nuvio.app.features.trakt.TraktScrobbleRepository import com.nuvio.app.features.updater.AppUpdaterHost import com.nuvio.app.features.updater.AppUpdaterPlatform import com.nuvio.app.features.updater.rememberAppUpdaterController @@ -1233,8 +1235,8 @@ private fun MainAppContent( null } val playerLaunch = lastExternalPlayerLaunch - if (TraktAuthRepository.isAuthenticated.value && progressPercent != null && playerLaunch != null) { - val scrobbleItem = TraktScrobbleRepository.buildItem( + if (progressPercent != null && playerLaunch != null) { + val trackingMedia = buildTrackingMediaReference( contentType = playerLaunch.parentMetaType, parentMetaId = playerLaunch.parentMetaId, videoId = playerLaunch.videoId, @@ -1243,12 +1245,15 @@ private fun MainAppContent( episodeNumber = playerLaunch.episodeNumber, episodeTitle = playerLaunch.episodeTitle, ) - if (scrobbleItem != null) { + if (trackingMedia.hasResolvableIdentity) { runCatching { - TraktScrobbleRepository.scrobbleStop( + TrackingScrobbleCoordinator.scrobble( profileId = playerLaunch.profileId, - item = scrobbleItem, - progressPercent = progressPercent, + action = TrackingScrobbleAction.STOP, + event = TrackingScrobbleEvent( + media = trackingMedia, + progressPercent = progressPercent.toDouble(), + ), ) } } 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 26f45848..34a9f27a 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 @@ -6,9 +6,11 @@ import com.nuvio.app.features.simkl.SimklLibraryRepository import com.nuvio.app.features.simkl.SimklProgressRepository import com.nuvio.app.features.simkl.SimklSyncRepository import com.nuvio.app.features.trakt.TraktAuthRepository +import com.nuvio.app.features.trakt.TraktScrobbleRepository fun ensureTrackingProvidersRegistered() { TraktAuthRepository.descriptor + TraktScrobbleRepository.ensureRegistered() SimklAuthRepository.descriptor SimklSyncRepository.state SimklLibraryRepository.uiState diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt index 02df155a..b06d0b7c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt @@ -15,6 +15,7 @@ import com.nuvio.app.features.streams.BingeGroupCacheRepository import com.nuvio.app.features.streams.StreamLinkCacheRepository import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.hasLikelyExpiringPlaybackCredentials +import com.nuvio.app.features.tracking.TrackingScrobbleAction import com.nuvio.app.features.watchprogress.WatchProgressRepository import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay @@ -68,7 +69,6 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() { initialLoadCompleted = false lastProgressPersistEpochMs = 0L previousIsPlaying = false - pendingScrobbleStartAfterSeek = false seekProgressSyncJob?.cancel() seekProgressSyncJob = null accumulatedSeekResetJob?.cancel() @@ -333,22 +333,17 @@ private fun PlayerScreenRuntime.BindPlayerUiVisibilityEffects() { playbackSnapshot.durationMs, ) { if (playbackSnapshot.isEnded) { - flushWatchProgress() + flushWatchProgress(TrackingScrobbleAction.STOP) previousIsPlaying = false - pendingScrobbleStartAfterSeek = false return@LaunchedEffect } if (previousIsPlaying && !playbackSnapshot.isPlaying && !playbackSnapshot.isLoading) { - pendingScrobbleStartAfterSeek = false - flushWatchProgress() + flushWatchProgress(TrackingScrobbleAction.PAUSE) } - if (playbackSnapshot.isPlaying && pendingScrobbleStartAfterSeek) { - pendingScrobbleStartAfterSeek = false - emitTraktScrobbleStart() - } else if (!previousIsPlaying && playbackSnapshot.isPlaying) { - emitTraktScrobbleStart() + if (!previousIsPlaying && playbackSnapshot.isPlaying) { + emitTrackingScrobbleStart() } if (!playbackSnapshot.isLoading) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt index 51654899..983b6637 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt @@ -1,7 +1,11 @@ package com.nuvio.app.features.player import com.nuvio.app.features.tmdb.TmdbService -import com.nuvio.app.features.trakt.TraktScrobbleRepository +import com.nuvio.app.features.tracking.TrackingMediaReference +import com.nuvio.app.features.tracking.TrackingScrobbleAction +import com.nuvio.app.features.tracking.TrackingScrobbleCoordinator +import com.nuvio.app.features.tracking.TrackingScrobbleEvent +import com.nuvio.app.features.tracking.buildTrackingMediaReference import com.nuvio.app.features.watchprogress.WatchProgressClock import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession import com.nuvio.app.features.watchprogress.WatchProgressRepository @@ -55,7 +59,6 @@ internal fun PlayerScreenRuntime.resetIdentityStateIfNeeded() { (activeInitialProgressFraction == null || activeInitialProgressFraction!! <= 0f) lastProgressPersistEpochMs = 0L previousIsPlaying = false - pendingScrobbleStartAfterSeek = false autoFetchedAddonSubtitlesForKey = null trackPreferenceRestoreApplied = false preferredAudioSelectionApplied = false @@ -67,9 +70,8 @@ internal fun PlayerScreenRuntime.resetIdentityStateIfNeeded() { lastResetVideoIdentity = videoIdentity hasRequestedScrobbleStartForCurrentItem = false scrobbleStartRequestGeneration = 0L - pendingScrobbleStartAfterSeek = false hasSentCompletionScrobbleForCurrentItem = false - currentTraktScrobbleItem = null + currentTrackingMedia = null } } @@ -81,7 +83,7 @@ internal fun PlayerScreenRuntime.currentPlaybackProgressPercent( .coerceIn(0f, 100f) } -internal data class TraktScrobbleItemInputs( +internal data class TrackingScrobbleItemInputs( val contentType: String, val parentMetaId: String, val videoId: String?, @@ -91,7 +93,7 @@ internal data class TraktScrobbleItemInputs( val episodeTitle: String?, ) -internal fun PlayerScreenRuntime.snapshotTraktScrobbleItemInputs() = TraktScrobbleItemInputs( +internal fun PlayerScreenRuntime.snapshotTrackingScrobbleItemInputs() = TrackingScrobbleItemInputs( contentType = contentType ?: parentMetaType, parentMetaId = parentMetaId, videoId = activeVideoId, @@ -101,8 +103,8 @@ internal fun PlayerScreenRuntime.snapshotTraktScrobbleItemInputs() = TraktScrobb episodeTitle = activeEpisodeTitle, ) -private suspend fun TraktScrobbleItemInputs.buildItem() = - TraktScrobbleRepository.buildItem( +private fun TrackingScrobbleItemInputs.buildMedia(): TrackingMediaReference = + buildTrackingMediaReference( contentType = contentType, parentMetaId = parentMetaId, videoId = videoId, @@ -112,49 +114,70 @@ private suspend fun TraktScrobbleItemInputs.buildItem() = episodeTitle = episodeTitle, ) -internal suspend fun PlayerScreenRuntime.currentTraktScrobbleItem() = - snapshotTraktScrobbleItemInputs().buildItem() +internal fun PlayerScreenRuntime.currentTrackingMedia(): TrackingMediaReference = + snapshotTrackingScrobbleItemInputs().buildMedia() -internal fun PlayerScreenRuntime.emitTraktScrobbleStart() { +internal fun PlayerScreenRuntime.emitTrackingScrobbleStart() { if (hasRequestedScrobbleStartForCurrentItem) return hasRequestedScrobbleStartForCurrentItem = true val requestGeneration = scrobbleStartRequestGeneration + 1L scrobbleStartRequestGeneration = requestGeneration scope.launch { - val item = currentTraktScrobbleItem() - if (item == null) { + val media = currentTrackingMedia() + if (!media.hasResolvableIdentity) { hasRequestedScrobbleStartForCurrentItem = false return@launch } if (requestGeneration != scrobbleStartRequestGeneration || !hasRequestedScrobbleStartForCurrentItem) { return@launch } - currentTraktScrobbleItem = item - TraktScrobbleRepository.scrobbleStart( + currentTrackingMedia = media + TrackingScrobbleCoordinator.scrobble( profileId = profileId, - item = item, - progressPercent = currentPlaybackProgressPercent(), + action = TrackingScrobbleAction.START, + event = TrackingScrobbleEvent( + media = media, + progressPercent = currentPlaybackProgressPercent().toDouble(), + ), ) } } -internal fun PlayerScreenRuntime.emitTraktScrobbleStop(progressPercent: Float? = null) { +internal fun PlayerScreenRuntime.emitTrackingScrobblePause(progressPercent: Float? = null) { + emitTrackingScrobbleTerminal( + action = TrackingScrobbleAction.PAUSE, + progressPercent = progressPercent, + ) +} + +internal fun PlayerScreenRuntime.emitTrackingScrobbleStop(progressPercent: Float? = null) { + emitTrackingScrobbleTerminal( + action = TrackingScrobbleAction.STOP, + progressPercent = progressPercent, + ) +} + +private fun PlayerScreenRuntime.emitTrackingScrobbleTerminal( + action: TrackingScrobbleAction, + progressPercent: Float?, +) { val provided = progressPercent if (!hasRequestedScrobbleStartForCurrentItem && (provided ?: 0f) < 80f) return val percent = provided ?: currentPlaybackProgressPercent() - val itemSnapshot = currentTraktScrobbleItem - val inputsSnapshot = snapshotTraktScrobbleItemInputs() + val mediaSnapshot = currentTrackingMedia + val inputsSnapshot = snapshotTrackingScrobbleItemInputs() scope.launch(NonCancellable) { - val item = itemSnapshot ?: inputsSnapshot.buildItem() ?: return@launch - TraktScrobbleRepository.scrobbleStop( + val media = mediaSnapshot ?: inputsSnapshot.buildMedia() + if (!media.hasResolvableIdentity) return@launch + TrackingScrobbleCoordinator.scrobble( profileId = profileId, - item = item, - progressPercent = percent, + action = action, + event = TrackingScrobbleEvent(media = media, progressPercent = percent.toDouble()), ) } - currentTraktScrobbleItem = null + currentTrackingMedia = null hasRequestedScrobbleStartForCurrentItem = false scrobbleStartRequestGeneration += 1L } @@ -162,13 +185,13 @@ internal fun PlayerScreenRuntime.emitTraktScrobbleStop(progressPercent: Float? = internal fun PlayerScreenRuntime.emitStopScrobbleForCurrentProgress() { val progressPercent = currentPlaybackProgressPercent() if (progressPercent >= 1f && progressPercent < 80f) { - emitTraktScrobbleStop(progressPercent) + emitTrackingScrobbleStop(progressPercent) return } if (progressPercent >= 80f && !hasSentCompletionScrobbleForCurrentItem) { hasSentCompletionScrobbleForCurrentItem = true - emitTraktScrobbleStop(progressPercent) + emitTrackingScrobbleStop(progressPercent) } } @@ -192,8 +215,14 @@ internal suspend fun PlayerScreenRuntime.resolveParentalGuideImdbId(): String? { ) } -internal fun PlayerScreenRuntime.flushWatchProgress() { - emitStopScrobbleForCurrentProgress() +internal fun PlayerScreenRuntime.flushWatchProgress( + scrobbleAction: TrackingScrobbleAction = TrackingScrobbleAction.STOP, +) { + when (scrobbleAction) { + TrackingScrobbleAction.PAUSE -> emitTrackingScrobblePause() + TrackingScrobbleAction.STOP -> emitStopScrobbleForCurrentProgress() + TrackingScrobbleAction.START -> Unit + } WatchProgressRepository.flushPlaybackProgress( session = playbackSession, snapshot = playbackSnapshot, @@ -201,7 +230,6 @@ internal fun PlayerScreenRuntime.flushWatchProgress() { } internal fun PlayerScreenRuntime.scheduleProgressSyncAfterSeek() { - val shouldRestartScrobbleAfterSeek = shouldPlay || playbackSnapshot.isPlaying seekProgressSyncJob?.cancel() seekProgressSyncJob = scope.launch { delay(PlayerSeekProgressSyncDebounceMs) @@ -210,17 +238,6 @@ internal fun PlayerScreenRuntime.scheduleProgressSyncAfterSeek() { snapshot = playbackSnapshot, ) - val progressPercent = currentPlaybackProgressPercent() - if (progressPercent >= 1f && progressPercent < 80f) { - emitTraktScrobbleStop(progressPercent) - val shouldRestartScrobbleNow = shouldRestartScrobbleAfterSeek && shouldPlay - if (shouldRestartScrobbleNow && playbackSnapshot.isPlaying) { - pendingScrobbleStartAfterSeek = false - emitTraktScrobbleStart() - } else if (shouldRestartScrobbleNow) { - pendingScrobbleStartAfterSeek = true - } - } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt index 43b4977a..b0ca8916 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt @@ -16,7 +16,7 @@ import com.nuvio.app.features.p2p.P2pStreamingState import com.nuvio.app.features.player.skip.NextEpisodeInfo import com.nuvio.app.features.player.skip.SkipInterval import com.nuvio.app.features.streams.StreamsUiState -import com.nuvio.app.features.trakt.TraktScrobbleItem +import com.nuvio.app.features.tracking.TrackingMediaReference import com.nuvio.app.features.watched.WatchedUiState import com.nuvio.app.features.watchprogress.WatchProgressUiState import kotlinx.coroutines.CoroutineScope @@ -149,9 +149,8 @@ internal class PlayerScreenRuntime( var previousIsPlaying by mutableStateOf(false) var hasRequestedScrobbleStartForCurrentItem by mutableStateOf(false) var scrobbleStartRequestGeneration by mutableStateOf(0L) - var pendingScrobbleStartAfterSeek by mutableStateOf(false) var hasSentCompletionScrobbleForCurrentItem by mutableStateOf(false) - var currentTraktScrobbleItem by mutableStateOf(null) + var currentTrackingMedia by mutableStateOf(null) var showSourcesPanel by mutableStateOf(false) var showEpisodesPanel by mutableStateOf(false) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt index 2dc0e15c..2f97cabc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt @@ -169,7 +169,13 @@ object SimklMutationRepository : TrackingListWriter, TrackingHistoryWriter, Trac event: TrackingScrobbleEvent, ) { if (!isActiveProfile(profileId)) return - service.scrobble(action, event) + SimklSyncRepository.ensureLoaded() + service.scrobble( + action = action, + event = event.copy( + media = SimklSyncRepository.state.value.snapshot.enrichMediaReference(event.media), + ), + ) } private fun isActiveProfile(profileId: Int): Boolean = ProfileRepository.activeProfileId == profileId diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt index 63b0fb22..54c739f4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt @@ -141,6 +141,24 @@ internal fun SimklSyncSnapshot.mediaReference( ) } +internal fun SimklSyncSnapshot.enrichMediaReference(reference: TrackingMediaReference): TrackingMediaReference { + val entry = entries.firstOrNull { candidate -> + candidate.media?.toTrackingExternalIds()?.sharesIdentityWith(reference.ids) == true + } ?: return reference + val media = entry.media ?: return reference + val kind = when (entry.mediaType) { + SimklMediaType.MOVIES -> TrackingMediaKind.MOVIE + SimklMediaType.SHOWS -> TrackingMediaKind.SHOW + SimklMediaType.ANIME -> TrackingMediaKind.ANIME + } + return reference.copy( + kind = kind, + title = media.title?.takeIf(String::isNotBlank) ?: reference.title, + year = media.year ?: reference.year, + ids = media.toTrackingExternalIds().mergeMissing(reference.ids), + ) +} + internal fun SimklMedia.toTrackingExternalIds(): TrackingExternalIds = TrackingExternalIds( simkl = ids.simklIdValue()?.toLongOrNull(), imdb = ids.idValue("imdb"), @@ -290,6 +308,16 @@ private fun SimklLibraryEntry.matchesContentId(contentId: String): Boolean { (parsed.kitsu != null && parsed.kitsu == candidateIds.kitsu) } +private fun TrackingExternalIds.sharesIdentityWith(other: TrackingExternalIds): Boolean = + (simkl != null && simkl == other.simkl) || + (!imdb.isNullOrBlank() && imdb.equals(other.imdb, ignoreCase = true)) || + (tmdb != null && tmdb == other.tmdb) || + (!tvdb.isNullOrBlank() && tvdb.equals(other.tvdb, ignoreCase = true)) || + (mal != null && mal == other.mal) || + (anidb != null && anidb == other.anidb) || + (anilist != null && anilist == other.anilist) || + (kitsu != null && kitsu == other.kitsu) + private fun Int.isLeapYear(): Boolean = (this % 4 == 0 && this % 100 != 0) || this % 400 == 0 private fun daysInMonths(year: Int): IntArray = if (year.isLeapYear()) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingMedia.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingMedia.kt index 8b23654a..16bbeeb7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingMedia.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingMedia.kt @@ -122,4 +122,31 @@ fun extractTrackingYear(value: String?): Int? = ?.let { YEAR_PATTERN.find(it)?.value } ?.toIntOrNull() +fun buildTrackingMediaReference( + contentType: String, + parentMetaId: String, + videoId: String? = null, + title: String? = null, + releaseInfo: String? = null, + seasonNumber: Int? = null, + episodeNumber: Int? = null, + episodeTitle: String? = null, +): TrackingMediaReference { + val ids = parseTrackingExternalIds(parentMetaId) + .mergeMissing(parseTrackingExternalIds(videoId)) + return TrackingMediaReference( + kind = trackingMediaKind(contentType, ids), + title = title?.trim()?.takeIf(String::isNotBlank), + year = extractTrackingYear(releaseInfo), + ids = ids, + episode = episodeNumber?.let { number -> + TrackingEpisode( + season = seasonNumber, + number = number, + title = episodeTitle, + ) + }, + ) +} + private val YEAR_PATTERN = Regex("(19|20)\\d{2}") diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinator.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinator.kt new file mode 100644 index 00000000..23e83be7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinator.kt @@ -0,0 +1,58 @@ +package com.nuvio.app.features.tracking + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.profiles.ProfileRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.supervisorScope + +data class TrackingScrobbleFailure( + val providerId: TrackingProviderId, + val cause: Throwable, +) + +object TrackingScrobbleCoordinator { + private val log = Logger.withTag("TrackingScrobble") + + suspend fun scrobble( + profileId: Int, + action: TrackingScrobbleAction, + event: TrackingScrobbleEvent, + ): List { + if (profileId != ProfileRepository.activeProfileId) return emptyList() + TrackingProviderRegistry.ensureLoaded() + val failures = dispatchTrackingScrobble( + scrobblers = TrackingProviderRegistry.connectedScrobblers(), + profileId = profileId, + action = action, + event = event, + ) + failures.forEach { failure -> + log.w(failure.cause) { + "${failure.providerId.storageId} scrobble ${action.wireValue} failed" + } + } + return failures + } +} + +internal suspend fun dispatchTrackingScrobble( + scrobblers: Collection, + profileId: Int, + action: TrackingScrobbleAction, + event: TrackingScrobbleEvent, +): List = supervisorScope { + scrobblers.map { scrobbler -> + async { + try { + scrobbler.scrobble(profileId = profileId, action = action, event = event) + null + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + TrackingScrobbleFailure(providerId = scrobbler.providerId, cause = error) + } + } + }.awaitAll().filterNotNull() +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt index 36f6ba59..55e7d304 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt @@ -4,6 +4,13 @@ import co.touchlab.kermit.Logger import com.nuvio.app.core.build.AppVersionConfig import com.nuvio.app.features.addons.httpRequestRaw import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.tracking.TrackingMediaKind +import com.nuvio.app.features.tracking.TrackingMediaReference +import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.TrackingProviderRegistry +import com.nuvio.app.features.tracking.TrackingScrobbleAction +import com.nuvio.app.features.tracking.TrackingScrobbleEvent +import com.nuvio.app.features.tracking.TrackingScrobbler import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.serialization.SerialName @@ -39,7 +46,9 @@ internal sealed interface TraktScrobbleItem { } } -internal object TraktScrobbleRepository { +internal object TraktScrobbleRepository : TrackingScrobbler { + override val providerId: TrackingProviderId = TrackingProviderId.TRAKT + private data class ScrobbleStamp( val profileId: Int, val action: String, @@ -62,6 +71,26 @@ internal object TraktScrobbleRepository { private val retryDelayMs = 1_500L private val serverOverloadedRetryDelayMs = 5_000L + init { + TrackingProviderRegistry.registerScrobbler(this) + } + + fun ensureRegistered() = Unit + + override suspend fun scrobble( + profileId: Int, + action: TrackingScrobbleAction, + event: TrackingScrobbleEvent, + ) { + val item = buildItem(event.media) ?: return + sendScrobble( + profileId = profileId, + action = action.wireValue, + item = item, + progressPercent = event.progressPercent.toFloat(), + ) + } + suspend fun scrobbleStart(profileId: Int, item: TraktScrobbleItem, progressPercent: Float) { sendScrobble(profileId = profileId, action = "start", item = item, progressPercent = progressPercent) } @@ -126,6 +155,45 @@ internal object TraktScrobbleRepository { } } + private suspend fun buildItem(media: TrackingMediaReference): TraktScrobbleItem? { + val ids = TraktExternalIds( + trakt = media.ids.trakt?.toTraktIntOrNull(), + imdb = media.ids.imdb?.takeIf(String::isNotBlank), + tmdb = media.ids.tmdb?.toTraktIntOrNull(), + ) + if (!ids.hasAnyId()) return null + if (media.kind == TrackingMediaKind.MOVIE) { + return TraktScrobbleItem.Movie( + title = media.title, + year = media.year, + ids = ids, + ) + } + + val episode = media.episode ?: return null + val season = episode.season ?: return null + val contentId = ids.imdb + ?: ids.tmdb?.let { value -> "tmdb:$value" } + ?: ids.trakt?.let { value -> "trakt:$value" } + ?: return null + val mappedEpisode = TraktEpisodeMappingService.resolveEpisodeMapping( + contentId = contentId, + contentType = "series", + videoId = null, + season = season, + episode = episode.number, + episodeTitle = episode.title, + ) + return TraktScrobbleItem.Episode( + showTitle = media.title, + showYear = media.year, + showIds = ids, + season = mappedEpisode?.season ?: season, + number = mappedEpisode?.episode ?: episode.number, + episodeTitle = episode.title, + ) + } + private suspend fun sendScrobble( profileId: Int, action: String, @@ -325,6 +393,8 @@ internal object TraktScrobbleRepository { } } +private fun Long.toTraktIntOrNull(): Int? = takeIf { value -> value in 1L..Int.MAX_VALUE.toLong() }?.toInt() + @Serializable private data class TraktScrobbleRequest( @SerialName("movie") val movie: TraktMovieBody? = null, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingMediaTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingMediaTest.kt index 009a7c4c..f6c5d1be 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingMediaTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingMediaTest.kt @@ -32,4 +32,24 @@ class TrackingMediaTest { assertEquals(TrackingMediaKind.MOVIE, trackingMediaKind("film")) assertFalse(TrackingExternalIds().hasAny) } + + @Test + fun `playback media builder falls back to video id and keeps episode coordinates`() { + val media = buildTrackingMediaReference( + contentType = "series", + parentMetaId = "addon_specific_identifier", + videoId = "tt4574334:2:7", + title = "Stranger Things", + releaseInfo = "2016–", + seasonNumber = 2, + episodeNumber = 7, + episodeTitle = "The Lost Sister", + ) + + assertEquals("tt4574334", media.ids.imdb) + assertEquals(TrackingMediaKind.SHOW, media.kind) + assertEquals(2016, media.year) + assertEquals(2, media.episode?.season) + assertEquals(7, media.episode?.number) + } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinatorTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinatorTest.kt new file mode 100644 index 00000000..a70b1ccc --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinatorTest.kt @@ -0,0 +1,47 @@ +package com.nuvio.app.features.tracking + +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals + +class TrackingScrobbleCoordinatorTest { + @Test + fun `fanout isolates one provider failure`() = runBlocking { + val successful = FakeScrobbler(TrackingProviderId.TRAKT) + val failing = FakeScrobbler(TrackingProviderId.SIMKL, failure = IllegalStateException("offline")) + val event = TrackingScrobbleEvent( + media = TrackingMediaReference( + kind = TrackingMediaKind.MOVIE, + ids = TrackingExternalIds(imdb = "tt0111161"), + ), + progressPercent = 42.5, + ) + + val failures = dispatchTrackingScrobble( + scrobblers = listOf(successful, failing), + profileId = 2, + action = TrackingScrobbleAction.PAUSE, + event = event, + ) + + assertEquals(1, successful.callCount) + assertEquals(1, failing.callCount) + assertEquals(listOf(TrackingProviderId.SIMKL), failures.map(TrackingScrobbleFailure::providerId)) + } + + private class FakeScrobbler( + override val providerId: TrackingProviderId, + private val failure: Throwable? = null, + ) : TrackingScrobbler { + var callCount: Int = 0 + + override suspend fun scrobble( + profileId: Int, + action: TrackingScrobbleAction, + event: TrackingScrobbleEvent, + ) { + callCount += 1 + failure?.let { throw it } + } + } +} From 5bbe3b9ef0d93b0b6c7702994124d3cfd997cae7 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:13:21 +0530 Subject: [PATCH 09/59] feat(tracking): add provider-aware settings --- .../composeResources/values/strings.xml | 30 ++ .../commonMain/kotlin/com/nuvio/app/App.kt | 18 +- .../com/nuvio/app/core/sync/SyncManager.kt | 22 +- .../app/features/details/MetaDetailsScreen.kt | 37 +- .../app/features/library/LibraryScreen.kt | 40 +- .../app/features/settings/SettingsModels.kt | 4 +- .../app/features/settings/SettingsRootPage.kt | 14 +- .../app/features/settings/SettingsScreen.kt | 46 +- .../app/features/settings/SettingsSearch.kt | 28 +- ...ettingsPage.kt => TrackingSettingsPage.kt} | 467 ++++++++++++------ .../app/features/tracking/TrackingSettings.kt | 32 ++ 11 files changed, 513 insertions(+), 225 deletions(-) rename composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/{TraktSettingsPage.kt => TrackingSettingsPage.kt} (64%) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingSettings.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 1e20233a..3d6b89d9 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -332,6 +332,7 @@ Welcome Back Library Trakt Library + Simkl Watchlist Home Library Profile @@ -438,6 +439,7 @@ Supporters & Contributors TMDB Enrichment Trakt + Tracking ABOUT Account and sync status ACCOUNT @@ -458,6 +460,7 @@ Change to a different profile. Switch Profile Open Trakt connection screen + Connect tracking services and choose where your library and watch progress live. No settings found. Search settings... RESULTS @@ -1116,6 +1119,29 @@ Open Trakt Login Your Save actions can now target Trakt watchlist and personal lists. Sign in with Trakt to enable list-based saving and Trakt library mode. + Connect one or more tracking services. Playback events are sent to every connected tracker, while you choose one source for library and watch progress views. + SOURCE PREFERENCES + TRACKING SERVICES + After approval, you will be redirected back automatically. + Connect Simkl + Connected as %1$s + Simkl user + Watchlist, history, playback progress, and scrobbles are synced with Simkl. + Disconnect + Finish Simkl sign in in your browser + Open Simkl Login + Sign in with Simkl to sync your plan-to-watch list, history, playback progress, and scrobbles. + Missing SIMKL_CLIENT_ID in local.properties. + Visit Simkl + Simkl did not accept the sign-in callback. Please try again. + The Simkl sign-in request expired. Start again. + Could not complete Simkl sign in. Please try again. + Simkl authorization expired or was revoked. Connect again. + Simkl + Simkl watchlist selected + Watch progress source set to Simkl + Choose which connected service or Nuvio Sync powers resume and Continue Watching. Scrobbles still go to every connected tracker. + View on Simkl Library Source Choose which library to use for saving and viewing your collection Library Source @@ -1636,6 +1662,10 @@ Your Trakt library is empty Couldn't load Trakt library Trakt Library + Add titles to your Simkl plan-to-watch list to see them here. + Your Simkl watchlist is empty + Couldn’t load Simkl watchlist + Simkl Watchlist Connect account Connect an account in Connected Services settings to browse playable files from your cloud library. No cloud account connected diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index dd53197a..a50dee88 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -887,7 +887,7 @@ private fun MainAppContent( val collectionsTitle = stringResource(Res.string.collections_header) val newCollectionTitle = stringResource(Res.string.collections_new) val detailsFallbackTitle = stringResource(Res.string.meta_section_details_title) - val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT + val isRemoteLibrarySource = libraryUiState.sourceMode != LibrarySourceMode.LOCAL var initialHomeReady by rememberSaveable(ownsAppRuntime) { mutableStateOf(!ownsAppRuntime) } @@ -1682,10 +1682,10 @@ private fun MainAppContent( ) } - val librarySectionSubtitle = if (libraryUiState.sourceMode == LibrarySourceMode.TRAKT) { - stringResource(Res.string.compose_catalog_subtitle_trakt_library) - } else { - stringResource(Res.string.compose_catalog_subtitle_library) + val librarySectionSubtitle = when (libraryUiState.sourceMode) { + LibrarySourceMode.LOCAL -> stringResource(Res.string.compose_catalog_subtitle_library) + LibrarySourceMode.TRAKT -> stringResource(Res.string.compose_catalog_subtitle_trakt_library) + LibrarySourceMode.SIMKL -> stringResource(Res.string.compose_catalog_subtitle_simkl_library) } val onLibrarySectionViewAllClick: (LibrarySection, LibrarySortOption) -> Unit = { section, sortOption -> @@ -3298,10 +3298,10 @@ private fun MainAppContent( watchedKeys = watchedUiState.watchedKeys, item = preview, ) - // Trakt items long-pressed outside the library open the list picker + // Remote-library items long-pressed outside the library open the list picker // instead of removing, so only true removals disintegrate. val removesFromLibrary = isSaved && - (posterActionTarget.libraryItem != null || !isTraktLibrarySource) + (posterActionTarget.libraryItem != null || !isRemoteLibrarySource) NuvioPosterZoomActionOverlay( imageUrl = selectedPosterAnchor?.imageUrl ?: preview.poster, title = preview.name, @@ -3326,7 +3326,7 @@ private fun MainAppContent( val libraryItem = posterActionTarget.libraryItem ?: preview.toLibraryItem(savedAtEpochMs = 0L) if (posterActionTarget.libraryItem != null) { - if (isTraktLibrarySource) { + if (isRemoteLibrarySource) { coroutineScope.launch { runCatching { val listKey = posterActionTarget.libraryListKey @@ -3349,7 +3349,7 @@ private fun MainAppContent( LibraryRepository.remove(libraryItem.id) } } else { - if (!isTraktLibrarySource) { + if (!isRemoteLibrarySource) { LibraryRepository.toggleSaved(libraryItem) } else { pickerItem = libraryItem diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt index f81af882..7a635833 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt @@ -11,11 +11,12 @@ import com.nuvio.app.features.library.LibrarySourceMode import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.profiles.ProfileRepository -import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktPlatformClock import com.nuvio.app.features.trakt.TraktSettingsRepository -import com.nuvio.app.features.trakt.effectiveLibrarySourceMode -import com.nuvio.app.features.trakt.shouldUseTraktProgress +import com.nuvio.app.features.tracking.TrackingProviderRegistry +import com.nuvio.app.features.tracking.WatchProgressSource +import com.nuvio.app.features.tracking.effectiveLibrarySourceMode +import com.nuvio.app.features.tracking.effectiveWatchProgressSource import com.nuvio.app.features.watchprogress.WatchProgressSourceCoordinator import kotlinx.atomicfu.locks.SynchronizedObject import kotlinx.atomicfu.locks.synchronized @@ -427,19 +428,18 @@ object SyncManager { continue } - TraktAuthRepository.ensureLoaded() + TrackingProviderRegistry.ensureLoaded() TraktSettingsRepository.ensureLoaded() - val traktAuthenticated = TraktAuthRepository.isAuthenticated.value val settings = TraktSettingsRepository.uiState.value val shouldPullLibrary = effectiveLibrarySourceMode( - isAuthenticated = traktAuthenticated, - source = settings.librarySourceMode, + requestedSource = settings.librarySourceMode, + isProviderAuthenticated = TrackingProviderRegistry::isAuthenticated, ) == LibrarySourceMode.LOCAL - val shouldPullWatchProgress = !shouldUseTraktProgress( - isAuthenticated = traktAuthenticated, - source = settings.watchProgressSource, - ) + val shouldPullWatchProgress = effectiveWatchProgressSource( + requestedSource = settings.watchProgressSource, + isProviderAuthenticated = TrackingProviderRegistry::isAuthenticated, + ) == WatchProgressSource.NUVIO_SYNC if (!shouldPullLibrary && !shouldPullWatchProgress) { continue diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 44911c48..87380ec3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.CheckCircle @@ -116,6 +117,7 @@ import com.nuvio.app.features.trakt.TraktCommentsSettings import com.nuvio.app.features.trakt.TraktConnectionMode import com.nuvio.app.features.trakt.TraktListTab import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.trailer.TrailerPlaybackResolver import com.nuvio.app.features.trailer.TrailerPlaybackSource import com.nuvio.app.features.watched.WatchedRepository @@ -395,6 +397,15 @@ fun MetaDetailsScreen( ) { LibraryRepository.isSaved(meta.id, meta.type) } + val simklSourceUrl = remember(libraryUiState.items, meta.id) { + libraryUiState.items + .firstOrNull { item -> + item.id == meta.id && + item.trackingProviderId == TrackingProviderId.SIMKL.storageId + } + ?.trackingSourceUrl + ?.takeIf { url -> url.startsWith("https://simkl.com/") } + } val isWatched = remember(watchedUiState.watchedKeys, fullyWatchedSeriesKeys, metaPreview) { WatchingState.isPosterWatched( watchedKeys = watchedUiState.watchedKeys, @@ -937,6 +948,7 @@ fun MetaDetailsScreen( playButtonLabel = playButtonLabel, isSaved = isSaved, isWatched = isWatched, + simklSourceUrl = simklSourceUrl, onPrimaryPlayClick = onPrimaryPlayClick, onPrimaryPlayLongClick = onPrimaryPlayLongClick, onSaveClick = toggleSaved, @@ -1576,6 +1588,7 @@ private fun LazyListScope.configuredMetaSectionItems( playButtonLabel: String, isSaved: Boolean, isWatched: Boolean, + simklSourceUrl: String?, onPrimaryPlayClick: () -> Unit, onPrimaryPlayLongClick: (() -> Unit)?, onSaveClick: () -> Unit, @@ -1652,6 +1665,7 @@ private fun LazyListScope.configuredMetaSectionItems( playButtonLabel = playButtonLabel, isSaved = isSaved, isWatched = isWatched, + simklSourceUrl = simklSourceUrl, onPrimaryPlayClick = onPrimaryPlayClick, onPrimaryPlayLongClick = onPrimaryPlayLongClick, onSaveClick = onSaveClick, @@ -1801,6 +1815,7 @@ private fun ConfiguredMetaSections( playButtonLabel: String, isSaved: Boolean, isWatched: Boolean, + simklSourceUrl: String?, onPrimaryPlayClick: () -> Unit, onPrimaryPlayLongClick: (() -> Unit)?, onSaveClick: () -> Unit, @@ -1840,6 +1855,7 @@ private fun ConfiguredMetaSections( animatedVisibilityScope: AnimatedVisibilityScope?, ) { val enabledItems = settings.items.filter { it.enabled } + val uriHandler = LocalUriHandler.current // Helper to check if a section actually has content to show val sectionHasContent: (MetaScreenSectionKey) -> Boolean = { key -> @@ -1863,8 +1879,8 @@ private fun ConfiguredMetaSections( MetaScreenSectionKey.ACTIONS -> { DetailActionButtons( playLabel = playButtonLabel, - secondaryActions = listOf( - DetailSecondaryAction( + secondaryActions = buildList { + add(DetailSecondaryAction( label = if (isWatched) { stringResource(Res.string.hero_mark_unwatched) } else { @@ -1877,8 +1893,8 @@ private fun ConfiguredMetaSections( }, isActive = isWatched, onClick = onWatchedClick, - ), - DetailSecondaryAction( + )) + add(DetailSecondaryAction( label = if (isSaved) { stringResource(Res.string.hero_remove_from_library) } else { @@ -1892,8 +1908,17 @@ private fun ConfiguredMetaSections( isActive = isSaved, onClick = onSaveClick, onLongClick = onSaveLongClick, - ), - ), + )) + simklSourceUrl?.let { sourceUrl -> + add( + DetailSecondaryAction( + label = stringResource(Res.string.details_view_on_simkl), + icon = Icons.AutoMirrored.Filled.OpenInNew, + onClick = { runCatching { uriHandler.openUri(sourceUrl) } }, + ), + ) + } + }, isTablet = isTablet, onPlayClick = onPrimaryPlayClick, onPlayLongClick = if (showManualPlayOption) onPrimaryPlayLongClick else null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt index ae561f6c..434f3c9a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt @@ -140,7 +140,7 @@ fun LibraryScreen( var selectedLibraryType by rememberSaveable { mutableStateOf(null) } val coroutineScope = rememberCoroutineScope() val listState = rememberLazyListState() - val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT + val isRemoteSource = uiState.sourceMode != LibrarySourceMode.LOCAL val effectiveSortOption = effectiveLibrarySortOption( selected = displaySettings.sortOption, sourceMode = uiState.sourceMode, @@ -174,7 +174,7 @@ fun LibraryScreen( } } - LaunchedEffect(networkStatusUiState.condition, isTraktSource) { + LaunchedEffect(networkStatusUiState.condition, isRemoteSource) { when (networkStatusUiState.condition) { NetworkCondition.NoInternet, NetworkCondition.ServersUnreachable, @@ -185,7 +185,7 @@ fun LibraryScreen( NetworkCondition.Online -> { if (!observedOfflineState) return@LaunchedEffect observedOfflineState = false - if (isTraktSource) { + if (isRemoteSource) { coroutineScope.launch { LibraryRepository.pullFromServer(ProfileRepository.activeProfileId) } @@ -246,10 +246,12 @@ fun LibraryScreen( NuvioScreenHeader( title = if (sourceMode == LibraryViewMode.Cloud) { stringResource(Res.string.library_title) - } else if (isTraktSource) { - stringResource(Res.string.library_trakt_title) } else { - stringResource(Res.string.library_title) + when (uiState.sourceMode) { + LibrarySourceMode.LOCAL -> stringResource(Res.string.library_title) + LibrarySourceMode.TRAKT -> stringResource(Res.string.library_trakt_title) + LibrarySourceMode.SIMKL -> stringResource(Res.string.library_simkl_title) + } }, modifier = Modifier.padding(horizontal = 16.dp), actions = { @@ -352,10 +354,10 @@ fun LibraryScreen( } else { HomeEmptyStateCard( modifier = Modifier.padding(horizontal = 16.dp), - title = if (isTraktSource) { - stringResource(Res.string.library_trakt_load_failed) - } else { - stringResource(Res.string.library_load_failed) + title = when (uiState.sourceMode) { + LibrarySourceMode.LOCAL -> stringResource(Res.string.library_load_failed) + LibrarySourceMode.TRAKT -> stringResource(Res.string.library_trakt_load_failed) + LibrarySourceMode.SIMKL -> stringResource(Res.string.library_simkl_load_failed) }, message = uiState.errorMessage.orEmpty(), actionLabel = stringResource(Res.string.action_retry), @@ -367,7 +369,7 @@ fun LibraryScreen( uiState.sections.isEmpty() -> { item { - if (networkStatusUiState.isOfflineLike && isTraktSource) { + if (networkStatusUiState.isOfflineLike && isRemoteSource) { NuvioNetworkOfflineCard( condition = networkStatusUiState.condition, modifier = Modifier.padding(horizontal = 16.dp), @@ -376,15 +378,15 @@ fun LibraryScreen( } else { HomeEmptyStateCard( modifier = Modifier.padding(horizontal = 16.dp), - title = if (isTraktSource) { - stringResource(Res.string.library_trakt_empty_title) - } else { - stringResource(Res.string.library_empty_title) + title = when (uiState.sourceMode) { + LibrarySourceMode.LOCAL -> stringResource(Res.string.library_empty_title) + LibrarySourceMode.TRAKT -> stringResource(Res.string.library_trakt_empty_title) + LibrarySourceMode.SIMKL -> stringResource(Res.string.library_simkl_empty_title) }, - message = if (isTraktSource) { - stringResource(Res.string.library_trakt_empty_message) - } else { - stringResource(Res.string.library_empty_message) + message = when (uiState.sourceMode) { + LibrarySourceMode.LOCAL -> stringResource(Res.string.library_empty_message) + LibrarySourceMode.TRAKT -> stringResource(Res.string.library_trakt_empty_message) + LibrarySourceMode.SIMKL -> stringResource(Res.string.library_simkl_empty_message) }, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt index 14bc6114..7f209ac9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt @@ -31,6 +31,7 @@ import nuvio.composeapp.generated.resources.compose_settings_page_streams import nuvio.composeapp.generated.resources.compose_settings_page_supporters_contributors import nuvio.composeapp.generated.resources.compose_settings_page_tmdb_enrichment import nuvio.composeapp.generated.resources.compose_settings_page_trakt +import nuvio.composeapp.generated.resources.compose_settings_page_tracking import nuvio.composeapp.generated.resources.settings_account import org.jetbrains.compose.resources.StringResource @@ -150,7 +151,8 @@ internal enum class SettingsPage( parentPage = Integrations, ), TraktAuthentication( - titleRes = Res.string.compose_settings_page_trakt, + // Keep the enum name for saved navigation-state compatibility. + titleRes = Res.string.compose_settings_page_tracking, category = SettingsCategory.Account, parentPage = Root, ), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt index a2676246..f57e1bca 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt @@ -45,13 +45,13 @@ import nuvio.composeapp.generated.resources.compose_settings_root_integrations_d import nuvio.composeapp.generated.resources.compose_settings_root_notifications_description import nuvio.composeapp.generated.resources.compose_settings_root_switch_profile_description import nuvio.composeapp.generated.resources.compose_settings_root_switch_profile_title -import nuvio.composeapp.generated.resources.compose_settings_root_trakt_description +import nuvio.composeapp.generated.resources.compose_settings_root_tracking_description import nuvio.composeapp.generated.resources.compose_settings_root_about_section import nuvio.composeapp.generated.resources.compose_settings_root_account_section import nuvio.composeapp.generated.resources.compose_settings_root_advanced_description import nuvio.composeapp.generated.resources.compose_settings_root_advanced_section import nuvio.composeapp.generated.resources.compose_settings_page_content_discovery -import nuvio.composeapp.generated.resources.compose_settings_page_trakt +import nuvio.composeapp.generated.resources.compose_settings_page_tracking import nuvio.composeapp.generated.resources.settings_playback_subtitle import nuvio.composeapp.generated.resources.updates_debug_test_description import nuvio.composeapp.generated.resources.updates_debug_test_title @@ -67,7 +67,7 @@ internal fun LazyListScope.settingsRootContent( onNotificationsClick: () -> Unit, onContentDiscoveryClick: () -> Unit, onIntegrationsClick: () -> Unit, - onTraktClick: () -> Unit, + onTrackingClick: () -> Unit, onSupportersContributorsClick: () -> Unit, onLicensesAttributionsClick: () -> Unit, onCheckForUpdatesClick: (() -> Unit)? = null, @@ -107,11 +107,11 @@ internal fun LazyListScope.settingsRootContent( ) SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = stringResource(Res.string.compose_settings_page_trakt), - description = stringResource(Res.string.compose_settings_root_trakt_description), - iconPainter = integrationLogoPainter(IntegrationLogo.Trakt), + title = stringResource(Res.string.compose_settings_page_tracking), + description = stringResource(Res.string.compose_settings_root_tracking_description), + icon = Icons.Rounded.Link, isTablet = isTablet, - onClick = onTraktClick, + onClick = onTrackingClick, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 00f9316c..a9987cbb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -71,11 +71,13 @@ import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.player.AndroidLibmpvVideoOutput import com.nuvio.app.features.player.AndroidPlaybackEngine import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.simkl.SimklAuthRepository +import com.nuvio.app.features.simkl.SimklAuthUiState import com.nuvio.app.features.trakt.TraktAuthUiState import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktCommentsSettings -import com.nuvio.app.features.trakt.TraktSettingsRepository -import com.nuvio.app.features.trakt.TraktSettingsUiState +import com.nuvio.app.features.tracking.TrackingSettingsRepository +import com.nuvio.app.features.tracking.TrackingSettingsUiState import com.nuvio.app.features.tmdb.TmdbSettings import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository @@ -172,13 +174,17 @@ fun SettingsScreen( TraktAuthRepository.ensureLoaded() TraktAuthRepository.uiState }.collectAsStateWithLifecycle() + val simklAuthUiState by remember { + SimklAuthRepository.ensureLoaded() + SimklAuthRepository.uiState + }.collectAsStateWithLifecycle() val traktCommentsEnabled by remember { TraktCommentsSettings.ensureLoaded() TraktCommentsSettings.enabled }.collectAsStateWithLifecycle() - val traktSettingsUiState by remember { - TraktSettingsRepository.ensureLoaded() - TraktSettingsRepository.uiState + val trackingSettingsUiState by remember { + TrackingSettingsRepository.ensureLoaded() + TrackingSettingsRepository.uiState }.collectAsStateWithLifecycle() val addonsUiState by remember { AddonRepository.initialize() @@ -400,8 +406,9 @@ fun SettingsScreen( mdbListSettings = mdbListSettings, debridSettings = debridSettings, traktAuthUiState = traktAuthUiState, + simklAuthUiState = simklAuthUiState, traktCommentsEnabled = traktCommentsEnabled, - traktSettingsUiState = traktSettingsUiState, + trackingSettingsUiState = trackingSettingsUiState, homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled, homescreenShowCatalogType = homescreenSettingsUiState.showCatalogType, homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent, @@ -461,8 +468,9 @@ fun SettingsScreen( mdbListSettings = mdbListSettings, debridSettings = debridSettings, traktAuthUiState = traktAuthUiState, + simklAuthUiState = simklAuthUiState, traktCommentsEnabled = traktCommentsEnabled, - traktSettingsUiState = traktSettingsUiState, + trackingSettingsUiState = trackingSettingsUiState, homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled, homescreenShowCatalogType = homescreenSettingsUiState.showCatalogType, homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent, @@ -532,8 +540,9 @@ private fun MobileSettingsScreen( mdbListSettings: MdbListSettings, debridSettings: DebridSettings, traktAuthUiState: TraktAuthUiState, + simklAuthUiState: SimklAuthUiState, traktCommentsEnabled: Boolean, - traktSettingsUiState: TraktSettingsUiState, + trackingSettingsUiState: TrackingSettingsUiState, homescreenHeroEnabled: Boolean, homescreenShowCatalogType: Boolean, homescreenHideUnreleasedContent: Boolean, @@ -664,7 +673,7 @@ private fun MobileSettingsScreen( onNotificationsClick = { onPageChange(SettingsPage.Notifications) }, onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) }, onIntegrationsClick = { onPageChange(SettingsPage.Integrations) }, - onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) }, + onTrackingClick = { onPageChange(SettingsPage.TraktAuthentication) }, onSupportersContributorsClick = onSupportersContributorsClick, onLicensesAttributionsClick = onLicensesAttributionsClick, onCheckForUpdatesClick = onCheckForUpdatesClick, @@ -793,10 +802,11 @@ private fun MobileSettingsScreen( isTablet = false, settings = debridSettings, ) - SettingsPage.TraktAuthentication -> traktSettingsContent( + SettingsPage.TraktAuthentication -> trackingSettingsContent( isTablet = false, - uiState = traktAuthUiState, - settingsUiState = traktSettingsUiState, + traktUiState = traktAuthUiState, + simklUiState = simklAuthUiState, + settingsUiState = trackingSettingsUiState, commentsEnabled = traktCommentsEnabled, onCommentsEnabledChange = TraktCommentsSettings::setEnabled, ) @@ -890,8 +900,9 @@ private fun TabletSettingsScreen( mdbListSettings: MdbListSettings, debridSettings: DebridSettings, traktAuthUiState: TraktAuthUiState, + simklAuthUiState: SimklAuthUiState, traktCommentsEnabled: Boolean, - traktSettingsUiState: TraktSettingsUiState, + trackingSettingsUiState: TrackingSettingsUiState, homescreenHeroEnabled: Boolean, homescreenShowCatalogType: Boolean, homescreenHideUnreleasedContent: Boolean, @@ -1074,7 +1085,7 @@ private fun TabletSettingsScreen( onNotificationsClick = { openInlinePage(SettingsPage.Notifications) }, onContentDiscoveryClick = { openInlinePage(SettingsPage.ContentDiscovery) }, onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) }, - onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) }, + onTrackingClick = { openInlinePage(SettingsPage.TraktAuthentication) }, onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) }, onLicensesAttributionsClick = { openInlinePage(SettingsPage.LicensesAttributions) }, onCheckForUpdatesClick = onCheckForUpdatesClick, @@ -1207,10 +1218,11 @@ private fun TabletSettingsScreen( isTablet = true, settings = debridSettings, ) - SettingsPage.TraktAuthentication -> traktSettingsContent( + SettingsPage.TraktAuthentication -> trackingSettingsContent( isTablet = true, - uiState = traktAuthUiState, - settingsUiState = traktSettingsUiState, + traktUiState = traktAuthUiState, + simklUiState = simklAuthUiState, + settingsUiState = trackingSettingsUiState, commentsEnabled = traktCommentsEnabled, onCommentsEnabledChange = TraktCommentsSettings::setEnabled, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt index 8f4aefce..ddcbb535 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt @@ -92,7 +92,7 @@ internal fun settingsSearchEntries( val advancedCategory = stringResource(SettingsCategory.Advanced.labelRes) val accountPage = stringResource(Res.string.compose_settings_page_account) - val traktPage = stringResource(Res.string.compose_settings_page_trakt) + val trackingPage = stringResource(Res.string.compose_settings_page_tracking) val layoutPage = stringResource(Res.string.compose_settings_page_appearance) val advancedPage = stringResource(Res.string.compose_settings_page_advanced) val contentDiscoveryPage = stringResource(Res.string.compose_settings_page_content_discovery) @@ -200,9 +200,9 @@ internal fun settingsSearchEntries( ) addPage( page = SettingsPage.TraktAuthentication, - key = "trakt", - title = traktPage, - description = stringResource(Res.string.compose_settings_root_trakt_description), + key = "tracking", + title = trackingPage, + description = stringResource(Res.string.compose_settings_root_tracking_description), category = accountCategory, icon = Icons.Rounded.Link, ) @@ -874,10 +874,20 @@ internal fun settingsSearchEntries( addRow( page = SettingsPage.TraktAuthentication, key = "trakt-authentication", - title = stringResource(Res.string.settings_trakt_authentication), + title = stringResource(Res.string.trakt_library_source_trakt), description = stringResource(Res.string.settings_trakt_intro_description), - pageLabel = traktPage, - section = stringResource(Res.string.settings_trakt_authentication), + pageLabel = trackingPage, + section = stringResource(Res.string.settings_tracking_services), + category = accountCategory, + icon = Icons.Rounded.Link, + ) + addRow( + page = SettingsPage.TraktAuthentication, + key = "simkl-authentication", + title = stringResource(Res.string.tracking_source_simkl), + description = stringResource(Res.string.settings_simkl_sign_in_description), + pageLabel = trackingPage, + section = stringResource(Res.string.settings_tracking_services), category = accountCategory, icon = Icons.Rounded.Link, ) @@ -893,8 +903,8 @@ internal fun settingsSearchEntries( key = row.key, title = row.title, description = row.description, - pageLabel = traktPage, - section = stringResource(Res.string.settings_trakt_features), + pageLabel = trackingPage, + section = stringResource(Res.string.settings_tracking_features), category = accountCategory, icon = Icons.Rounded.Link, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingSettingsPage.kt similarity index 64% rename from composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt rename to composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingSettingsPage.kt index 3bdb0ebc..038366da 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingSettingsPage.kt @@ -24,6 +24,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -33,25 +34,29 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.nuvio.app.features.library.LibrarySourceMode import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.simkl.SimklAuthError +import com.nuvio.app.features.simkl.SimklAuthRepository +import com.nuvio.app.features.simkl.SimklAuthUiState +import com.nuvio.app.features.simkl.SimklConnectionMode import com.nuvio.app.features.trakt.TraktAuthRepository -import com.nuvio.app.features.trakt.TraktBrandAsset import com.nuvio.app.features.trakt.TraktAuthUiState import com.nuvio.app.features.trakt.TraktConnectionMode import com.nuvio.app.features.trakt.TraktContinueWatchingDaysOptions import com.nuvio.app.features.trakt.MoreLikeThisSourcePreference -import com.nuvio.app.features.trakt.TraktSettingsRepository -import com.nuvio.app.features.trakt.TraktSettingsUiState +import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.TrackingSettingsRepository +import com.nuvio.app.features.tracking.TrackingSettingsUiState import com.nuvio.app.features.tracking.WatchProgressSource +import com.nuvio.app.features.tracking.effectiveLibrarySourceMode +import com.nuvio.app.features.tracking.effectiveWatchProgressSource import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap -import com.nuvio.app.features.trakt.traktBrandPainter import com.nuvio.app.features.watchprogress.WatchProgressSourceCoordinator import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.Res @@ -73,6 +78,27 @@ import nuvio.composeapp.generated.resources.settings_trakt_missing_credentials import nuvio.composeapp.generated.resources.settings_trakt_open_login import nuvio.composeapp.generated.resources.settings_trakt_save_actions_description import nuvio.composeapp.generated.resources.settings_trakt_sign_in_description +import nuvio.composeapp.generated.resources.settings_tracking_approval_redirect +import nuvio.composeapp.generated.resources.settings_tracking_features +import nuvio.composeapp.generated.resources.settings_tracking_intro_description +import nuvio.composeapp.generated.resources.settings_simkl_authorization_expired +import nuvio.composeapp.generated.resources.settings_simkl_authorization_revoked +import nuvio.composeapp.generated.resources.settings_simkl_connect +import nuvio.composeapp.generated.resources.settings_simkl_connected_as +import nuvio.composeapp.generated.resources.settings_simkl_connected_description +import nuvio.composeapp.generated.resources.settings_simkl_default_user +import nuvio.composeapp.generated.resources.settings_simkl_disconnect +import nuvio.composeapp.generated.resources.settings_simkl_finish_sign_in +import nuvio.composeapp.generated.resources.settings_simkl_invalid_callback +import nuvio.composeapp.generated.resources.settings_simkl_missing_credentials +import nuvio.composeapp.generated.resources.settings_simkl_open_login +import nuvio.composeapp.generated.resources.settings_simkl_sign_in_description +import nuvio.composeapp.generated.resources.settings_simkl_sign_in_failed +import nuvio.composeapp.generated.resources.settings_simkl_visit +import nuvio.composeapp.generated.resources.tracking_library_source_simkl_selected +import nuvio.composeapp.generated.resources.tracking_source_simkl +import nuvio.composeapp.generated.resources.tracking_watch_progress_dialog_subtitle +import nuvio.composeapp.generated.resources.tracking_watch_progress_simkl_selected import nuvio.composeapp.generated.resources.trakt_all_history import nuvio.composeapp.generated.resources.trakt_continue_watching_subtitle import nuvio.composeapp.generated.resources.trakt_continue_watching_window @@ -103,56 +129,73 @@ import nuvio.composeapp.generated.resources.trakt_watch_progress_title import nuvio.composeapp.generated.resources.trakt_watch_progress_trakt_selected import org.jetbrains.compose.resources.stringResource -internal fun LazyListScope.traktSettingsContent( +internal fun LazyListScope.trackingSettingsContent( isTablet: Boolean, - uiState: TraktAuthUiState, - settingsUiState: TraktSettingsUiState, + traktUiState: TraktAuthUiState, + simklUiState: SimklAuthUiState, + settingsUiState: TrackingSettingsUiState, commentsEnabled: Boolean, onCommentsEnabledChange: (Boolean) -> Unit, ) { item { SettingsGroup(isTablet = isTablet) { - TraktBrandIntro(isTablet = isTablet) + TrackingIntro(isTablet = isTablet) } } item { SettingsSection( - title = stringResource(Res.string.settings_trakt_authentication), + title = "TRAKT", isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { TraktConnectionCard( isTablet = isTablet, - uiState = uiState, + uiState = traktUiState, ) } } } - if (uiState.mode == TraktConnectionMode.CONNECTED) { - item { - SettingsSection( - title = stringResource(Res.string.settings_trakt_features), - isTablet = isTablet, - ) { - SettingsGroup(isTablet = isTablet) { - TraktFeatureRows( - isTablet = isTablet, - settingsUiState = settingsUiState, - commentsEnabled = commentsEnabled, - onCommentsEnabledChange = onCommentsEnabledChange, - ) - } + item { + SettingsSection( + title = "SIMKL", + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + SimklConnectionCard( + isTablet = isTablet, + uiState = simklUiState, + ) + } + } + } + + item { + SettingsSection( + title = stringResource(Res.string.settings_tracking_features), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + TrackingFeatureRows( + isTablet = isTablet, + settingsUiState = settingsUiState, + traktConnected = traktUiState.mode == TraktConnectionMode.CONNECTED, + simklConnected = simklUiState.mode == SimklConnectionMode.CONNECTED, + commentsEnabled = commentsEnabled, + onCommentsEnabledChange = onCommentsEnabledChange, + ) } } } } @Composable -private fun TraktFeatureRows( +private fun TrackingFeatureRows( isTablet: Boolean, - settingsUiState: TraktSettingsUiState, + settingsUiState: TrackingSettingsUiState, + traktConnected: Boolean, + simklConnected: Boolean, commentsEnabled: Boolean, onCommentsEnabledChange: (Boolean) -> Unit, ) { @@ -163,16 +206,39 @@ private fun TraktFeatureRows( var statusMessage by rememberSaveable { mutableStateOf(null) } val scope = rememberCoroutineScope() - val librarySourceValue = librarySourceModeLabel(settingsUiState.librarySourceMode) - val watchProgressValue = watchProgressSourceLabel(settingsUiState.watchProgressSource) + val connectedProviders = buildSet { + if (traktConnected) add(TrackingProviderId.TRAKT) + if (simklConnected) add(TrackingProviderId.SIMKL) + } + val selectedLibrarySource = effectiveLibrarySourceMode(settingsUiState.librarySourceMode) { + providerId -> providerId in connectedProviders + } + val selectedWatchProgressSource = effectiveWatchProgressSource(settingsUiState.watchProgressSource) { + providerId -> providerId in connectedProviders + } + val availableLibrarySources = buildList { + add(LibrarySourceMode.LOCAL) + if (traktConnected) add(LibrarySourceMode.TRAKT) + if (simklConnected) add(LibrarySourceMode.SIMKL) + } + val availableWatchProgressSources = buildList { + add(WatchProgressSource.NUVIO_SYNC) + if (traktConnected) add(WatchProgressSource.TRAKT) + if (simklConnected) add(WatchProgressSource.SIMKL) + } + + val librarySourceValue = librarySourceModeLabel(selectedLibrarySource) + val watchProgressValue = watchProgressSourceLabel(selectedWatchProgressSource) val continueWatchingWindowValue = continueWatchingDaysCapLabel(settingsUiState.continueWatchingDaysCap) val moreLikeThisSourceValue = moreLikeThisSourceLabel(settingsUiState.moreLikeThisSource) val traktProgressSelectedMessage = stringResource(Res.string.trakt_watch_progress_trakt_selected) + val simklProgressSelectedMessage = stringResource(Res.string.tracking_watch_progress_simkl_selected) val nuvioProgressSelectedMessage = stringResource(Res.string.trakt_watch_progress_nuvio_selected) val traktLibrarySelectedMessage = stringResource(Res.string.trakt_library_source_trakt_selected) + val simklLibrarySelectedMessage = stringResource(Res.string.tracking_library_source_simkl_selected) val nuvioLibrarySelectedMessage = stringResource(Res.string.trakt_library_source_nuvio_selected) - TraktSettingsActionRow( + TrackingSettingsActionRow( title = stringResource(Res.string.trakt_library_source_title), description = stringResource(Res.string.trakt_library_source_subtitle), value = librarySourceValue, @@ -180,7 +246,7 @@ private fun TraktFeatureRows( onClick = { showLibrarySourceDialog = true }, ) SettingsGroupDivider(isTablet = isTablet) - TraktSettingsActionRow( + TrackingSettingsActionRow( title = stringResource(Res.string.trakt_watch_progress_title), description = stringResource(Res.string.trakt_watch_progress_subtitle), value = watchProgressValue, @@ -188,32 +254,34 @@ private fun TraktFeatureRows( onClick = { showWatchProgressDialog = true }, ) SettingsGroupDivider(isTablet = isTablet) - TraktSettingsActionRow( + TrackingSettingsActionRow( title = stringResource(Res.string.trakt_continue_watching_window), description = stringResource(Res.string.trakt_continue_watching_subtitle), value = continueWatchingWindowValue, isTablet = isTablet, onClick = { showContinueWatchingWindowDialog = true }, ) - SettingsGroupDivider(isTablet = isTablet) - SettingsSwitchRow( - title = stringResource(Res.string.settings_trakt_comments), - description = stringResource(Res.string.settings_trakt_comments_description), - checked = commentsEnabled, - isTablet = isTablet, - onCheckedChange = onCommentsEnabledChange, - ) - SettingsGroupDivider(isTablet = isTablet) - TraktSettingsActionRow( - title = stringResource(Res.string.trakt_more_like_this_source_title), - description = stringResource(Res.string.trakt_more_like_this_source_subtitle), - value = moreLikeThisSourceValue, - isTablet = isTablet, - onClick = { showMoreLikeThisSourceDialog = true }, - ) + if (traktConnected) { + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_trakt_comments), + description = stringResource(Res.string.settings_trakt_comments_description), + checked = commentsEnabled, + isTablet = isTablet, + onCheckedChange = onCommentsEnabledChange, + ) + SettingsGroupDivider(isTablet = isTablet) + TrackingSettingsActionRow( + title = stringResource(Res.string.trakt_more_like_this_source_title), + description = stringResource(Res.string.trakt_more_like_this_source_subtitle), + value = moreLikeThisSourceValue, + isTablet = isTablet, + onClick = { showMoreLikeThisSourceDialog = true }, + ) + } statusMessage?.takeIf { it.isNotBlank() }?.let { message -> SettingsGroupDivider(isTablet = isTablet) - TraktInfoRow( + TrackingInfoRow( isTablet = isTablet, text = message, ) @@ -221,13 +289,14 @@ private fun TraktFeatureRows( if (showLibrarySourceDialog) { LibrarySourceModeDialog( - selectedSource = settingsUiState.librarySourceMode, + selectedSource = selectedLibrarySource, + availableSources = availableLibrarySources, onSourceSelected = { source -> - TraktSettingsRepository.setLibrarySourceMode(source) - statusMessage = if (source == LibrarySourceMode.TRAKT) { - traktLibrarySelectedMessage - } else { - nuvioLibrarySelectedMessage + TrackingSettingsRepository.setLibrarySourceMode(source) + statusMessage = when (source) { + LibrarySourceMode.LOCAL -> nuvioLibrarySelectedMessage + LibrarySourceMode.TRAKT -> traktLibrarySelectedMessage + LibrarySourceMode.SIMKL -> simklLibrarySelectedMessage } showLibrarySourceDialog = false }, @@ -237,7 +306,8 @@ private fun TraktFeatureRows( if (showWatchProgressDialog) { WatchProgressSourceDialog( - selectedSource = settingsUiState.watchProgressSource, + selectedSource = selectedWatchProgressSource, + availableSources = availableWatchProgressSources, onSourceSelected = { source -> scope.launch { val result = WatchProgressSourceCoordinator.selectSource( @@ -245,10 +315,10 @@ private fun TraktFeatureRows( source = source, ) statusMessage = if (result.succeeded) { - if (result.requestedSource == WatchProgressSource.TRAKT) { - traktProgressSelectedMessage - } else { - nuvioProgressSelectedMessage + when (result.requestedSource) { + WatchProgressSource.TRAKT -> traktProgressSelectedMessage + WatchProgressSource.SIMKL -> simklProgressSelectedMessage + WatchProgressSource.NUVIO_SYNC -> nuvioProgressSelectedMessage } } else { null @@ -264,7 +334,7 @@ private fun TraktFeatureRows( ContinueWatchingWindowDialog( selectedDaysCap = settingsUiState.continueWatchingDaysCap, onDaysCapSelected = { days -> - TraktSettingsRepository.setContinueWatchingDaysCap(days) + TrackingSettingsRepository.setContinueWatchingDaysCap(days) showContinueWatchingWindowDialog = false }, onDismiss = { showContinueWatchingWindowDialog = false }, @@ -275,7 +345,7 @@ private fun TraktFeatureRows( MoreLikeThisSourceDialog( selectedSource = settingsUiState.moreLikeThisSource, onSourceSelected = { source -> - TraktSettingsRepository.setMoreLikeThisSource(source) + TrackingSettingsRepository.setMoreLikeThisSource(source) showMoreLikeThisSourceDialog = false }, onDismiss = { showMoreLikeThisSourceDialog = false }, @@ -284,7 +354,7 @@ private fun TraktFeatureRows( } @Composable -private fun TraktSettingsActionRow( +private fun TrackingSettingsActionRow( title: String, description: String, value: String, @@ -333,7 +403,7 @@ private fun TraktSettingsActionRow( } @Composable -private fun TraktInfoRow( +private fun TrackingInfoRow( isTablet: Boolean, text: String, ) { @@ -355,7 +425,7 @@ private fun librarySourceModeLabel(source: LibrarySourceMode): String = when (source) { LibrarySourceMode.TRAKT -> stringResource(Res.string.trakt_library_source_trakt) LibrarySourceMode.LOCAL -> stringResource(Res.string.trakt_library_source_nuvio) - LibrarySourceMode.SIMKL -> "Simkl" + LibrarySourceMode.SIMKL -> stringResource(Res.string.tracking_source_simkl) } @Composable @@ -363,7 +433,7 @@ private fun watchProgressSourceLabel(source: WatchProgressSource): String = when (source) { WatchProgressSource.TRAKT -> stringResource(Res.string.trakt_watch_progress_source_trakt) WatchProgressSource.NUVIO_SYNC -> stringResource(Res.string.trakt_watch_progress_source_nuvio) - WatchProgressSource.SIMKL -> "Simkl" + WatchProgressSource.SIMKL -> stringResource(Res.string.tracking_source_simkl) } @Composable @@ -387,6 +457,7 @@ private fun continueWatchingDaysCapLabel(daysCap: Int): String { @OptIn(ExperimentalMaterial3Api::class) private fun LibrarySourceModeDialog( selectedSource: LibrarySourceMode, + availableSources: List, onSourceSelected: (LibrarySourceMode) -> Unit, onDismiss: () -> Unit, ) { @@ -416,8 +487,8 @@ private fun LibrarySourceModeDialog( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - listOf(LibrarySourceMode.TRAKT, LibrarySourceMode.LOCAL).forEach { source -> - TraktDialogOption( + availableSources.forEach { source -> + TrackingDialogOption( label = librarySourceModeLabel(source), selected = source == selectedSource, onClick = { onSourceSelected(source) }, @@ -440,6 +511,7 @@ private fun LibrarySourceModeDialog( @OptIn(ExperimentalMaterial3Api::class) private fun WatchProgressSourceDialog( selectedSource: WatchProgressSource, + availableSources: List, onSourceSelected: (WatchProgressSource) -> Unit, onDismiss: () -> Unit, ) { @@ -460,7 +532,7 @@ private fun WatchProgressSourceDialog( fontWeight = FontWeight.SemiBold, ) Text( - text = stringResource(Res.string.trakt_watch_progress_dialog_subtitle), + text = stringResource(Res.string.tracking_watch_progress_dialog_subtitle), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -469,8 +541,8 @@ private fun WatchProgressSourceDialog( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - listOf(WatchProgressSource.TRAKT, WatchProgressSource.NUVIO_SYNC).forEach { source -> - TraktDialogOption( + availableSources.forEach { source -> + TrackingDialogOption( label = watchProgressSourceLabel(source), selected = source == selectedSource, onClick = { onSourceSelected(source) }, @@ -526,7 +598,7 @@ private fun ContinueWatchingWindowDialog( ) { TraktContinueWatchingDaysOptions.forEach { days -> val normalizedDays = normalizeTraktContinueWatchingDaysCap(days) - TraktDialogOption( + TrackingDialogOption( label = continueWatchingDaysCapLabel(days), selected = normalizedDays == normalizedSelected, onClick = { onDaysCapSelected(days) }, @@ -579,7 +651,7 @@ private fun MoreLikeThisSourceDialog( verticalArrangement = Arrangement.spacedBy(8.dp), ) { listOf(MoreLikeThisSourcePreference.TRAKT, MoreLikeThisSourcePreference.TMDB).forEach { source -> - TraktDialogOption( + TrackingDialogOption( label = moreLikeThisSourceLabel(source), selected = source == selectedSource, onClick = { onSourceSelected(source) }, @@ -599,7 +671,7 @@ private fun MoreLikeThisSourceDialog( } @Composable -private fun TraktDialogOption( +private fun TrackingDialogOption( label: String, selected: Boolean, onClick: () -> Unit, @@ -646,48 +718,139 @@ private fun TraktDialogOption( } @Composable -private fun TraktBrandIntro( - isTablet: Boolean, -) { +private fun TrackingIntro(isTablet: Boolean) { val horizontalPadding = if (isTablet) 20.dp else 16.dp val verticalPadding = if (isTablet) 18.dp else 16.dp - Column( + Text( + text = stringResource(Res.string.settings_tracking_intro_description), modifier = Modifier .fillMaxWidth() .padding(horizontal = horizontalPadding, vertical = verticalPadding), - verticalArrangement = Arrangement.spacedBy(12.dp), - horizontalAlignment = Alignment.Start, - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(14.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - androidx.compose.foundation.Image( - painter = traktBrandPainter(TraktBrandAsset.Glyph), - contentDescription = null, - modifier = Modifier.size(if (isTablet) 84.dp else 72.dp), - contentScale = ContentScale.Fit, - ) - Text( - text = stringResource(Res.string.settings_trakt_intro_description), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +private enum class ConnectionCardMode { + DISCONNECTED, + AWAITING_APPROVAL, + CONNECTED, } @Composable private fun TraktConnectionCard( isTablet: Boolean, uiState: TraktAuthUiState, +) { + ProviderConnectionCard( + isTablet = isTablet, + mode = when (uiState.mode) { + TraktConnectionMode.DISCONNECTED -> ConnectionCardMode.DISCONNECTED + TraktConnectionMode.AWAITING_APPROVAL -> ConnectionCardMode.AWAITING_APPROVAL + TraktConnectionMode.CONNECTED -> ConnectionCardMode.CONNECTED + }, + credentialsConfigured = uiState.credentialsConfigured, + isLoading = uiState.isLoading, + connectedLabel = stringResource( + Res.string.settings_trakt_connected_as, + uiState.username ?: stringResource(Res.string.settings_trakt_default_user), + ), + connectedDescription = stringResource(Res.string.settings_trakt_save_actions_description), + signInDescription = stringResource(Res.string.settings_trakt_sign_in_description), + finishSignInLabel = stringResource(Res.string.settings_trakt_finish_sign_in), + approvalDescription = stringResource(Res.string.settings_trakt_approval_redirect), + connectLabel = stringResource(Res.string.settings_trakt_connect), + openLoginLabel = stringResource(Res.string.settings_trakt_open_login), + disconnectLabel = stringResource(Res.string.settings_trakt_disconnect), + missingCredentialsMessage = stringResource(Res.string.settings_trakt_missing_credentials), + statusMessage = uiState.statusMessage, + errorMessage = uiState.errorMessage, + onConnectRequested = TraktAuthRepository::onConnectRequested, + onResumeAuthorization = { + TraktAuthRepository.pendingAuthorizationUrl() + ?: TraktAuthRepository.onConnectRequested() + }, + onCancelAuthorization = TraktAuthRepository::onCancelAuthorization, + onDisconnect = TraktAuthRepository::onDisconnectRequested, + ) +} + +@Composable +private fun SimklConnectionCard( + isTablet: Boolean, + uiState: SimklAuthUiState, +) { + ProviderConnectionCard( + isTablet = isTablet, + mode = when (uiState.mode) { + SimklConnectionMode.DISCONNECTED -> ConnectionCardMode.DISCONNECTED + SimklConnectionMode.AWAITING_APPROVAL -> ConnectionCardMode.AWAITING_APPROVAL + SimklConnectionMode.CONNECTED -> ConnectionCardMode.CONNECTED + }, + credentialsConfigured = uiState.credentialsConfigured, + isLoading = uiState.isLoading, + connectedLabel = stringResource( + Res.string.settings_simkl_connected_as, + uiState.username ?: stringResource(Res.string.settings_simkl_default_user), + ), + connectedDescription = stringResource(Res.string.settings_simkl_connected_description), + signInDescription = stringResource(Res.string.settings_simkl_sign_in_description), + finishSignInLabel = stringResource(Res.string.settings_simkl_finish_sign_in), + approvalDescription = stringResource(Res.string.settings_tracking_approval_redirect), + connectLabel = stringResource(Res.string.settings_simkl_connect), + openLoginLabel = stringResource(Res.string.settings_simkl_open_login), + disconnectLabel = stringResource(Res.string.settings_simkl_disconnect), + missingCredentialsMessage = stringResource(Res.string.settings_simkl_missing_credentials), + errorMessage = simklErrorMessage(uiState.error), + websiteLabel = stringResource(Res.string.settings_simkl_visit), + websiteUrl = SIMKL_WEBSITE_URL, + onConnectRequested = SimklAuthRepository::onConnectRequested, + onResumeAuthorization = { + SimklAuthRepository.pendingAuthorizationUrl() + ?: SimklAuthRepository.onConnectRequested() + }, + onCancelAuthorization = SimklAuthRepository::onCancelAuthorization, + onDisconnect = SimklAuthRepository::onDisconnectRequested, + ) +} + +@Composable +private fun ProviderConnectionCard( + isTablet: Boolean, + mode: ConnectionCardMode, + credentialsConfigured: Boolean, + isLoading: Boolean, + connectedLabel: String, + connectedDescription: String, + signInDescription: String, + finishSignInLabel: String, + approvalDescription: String, + connectLabel: String, + openLoginLabel: String, + disconnectLabel: String, + missingCredentialsMessage: String, + statusMessage: String? = null, + errorMessage: String? = null, + websiteLabel: String? = null, + websiteUrl: String? = null, + onConnectRequested: () -> String?, + onResumeAuthorization: () -> String?, + onCancelAuthorization: () -> Unit, + onDisconnect: () -> Unit, ) { val uriHandler = LocalUriHandler.current val horizontalPadding = if (isTablet) 20.dp else 16.dp val verticalPadding = if (isTablet) 18.dp else 16.dp val failedOpenBrowserMessage = stringResource(Res.string.settings_trakt_failed_open_browser) + var browserError by rememberSaveable { mutableStateOf(null) } + + fun openUrl(url: String?) { + if (url.isNullOrBlank()) return + browserError = null + runCatching { uriHandler.openUri(url) } + .onFailure { error -> browserError = error.message ?: failedOpenBrowserMessage } + } Column( modifier = Modifier @@ -695,72 +858,59 @@ private fun TraktConnectionCard( .padding(horizontal = horizontalPadding, vertical = verticalPadding), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - when (uiState.mode) { - TraktConnectionMode.CONNECTED -> { + when (mode) { + ConnectionCardMode.CONNECTED -> { Text( - text = stringResource( - Res.string.settings_trakt_connected_as, - uiState.username ?: stringResource(Res.string.settings_trakt_default_user), - ), + text = connectedLabel, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Medium, ) Text( - text = stringResource(Res.string.settings_trakt_save_actions_description), + text = connectedDescription, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Button( - onClick = TraktAuthRepository::onDisconnectRequested, - enabled = !uiState.isLoading, + onClick = onDisconnect, + enabled = !isLoading, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.surfaceVariant, contentColor = MaterialTheme.colorScheme.onSurface, ), ) { - if (uiState.isLoading) { + if (isLoading) { NuvioLoadingIndicator( color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(18.dp), ) } else { - Text(stringResource(Res.string.settings_trakt_disconnect)) + Text(disconnectLabel) } } } - TraktConnectionMode.AWAITING_APPROVAL -> { + ConnectionCardMode.AWAITING_APPROVAL -> { Text( - text = stringResource(Res.string.settings_trakt_finish_sign_in), + text = finishSignInLabel, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Medium, ) Text( - text = stringResource(Res.string.settings_trakt_approval_redirect), + text = approvalDescription, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Button( - onClick = { - val authUrl = TraktAuthRepository.pendingAuthorizationUrl() - ?: TraktAuthRepository.onConnectRequested() - if (authUrl == null) return@Button - runCatching { uriHandler.openUri(authUrl) } - .onFailure { - TraktAuthRepository.onAuthLaunchFailed( - it.message ?: failedOpenBrowserMessage, - ) - } - }, - enabled = !uiState.isLoading, + onClick = { openUrl(onResumeAuthorization()) }, + enabled = !isLoading, ) { - Text(stringResource(Res.string.settings_trakt_open_login)) + Text(openLoginLabel) } Button( - onClick = TraktAuthRepository::onCancelAuthorization, - enabled = !uiState.isLoading, + onClick = onCancelAuthorization, + enabled = !isLoading, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.surfaceVariant, contentColor = MaterialTheme.colorScheme.onSurface, @@ -770,36 +920,28 @@ private fun TraktConnectionCard( } } - TraktConnectionMode.DISCONNECTED -> { + ConnectionCardMode.DISCONNECTED -> { Text( - text = stringResource(Res.string.settings_trakt_sign_in_description), + text = signInDescription, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Button( - onClick = { - val authUrl = TraktAuthRepository.onConnectRequested() ?: return@Button - runCatching { uriHandler.openUri(authUrl) } - .onFailure { - TraktAuthRepository.onAuthLaunchFailed( - it.message ?: failedOpenBrowserMessage, - ) - } - }, - enabled = uiState.credentialsConfigured && !uiState.isLoading, + onClick = { openUrl(onConnectRequested()) }, + enabled = credentialsConfigured && !isLoading, ) { - if (uiState.isLoading) { + if (isLoading) { NuvioLoadingIndicator( color = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(18.dp), ) } else { - Text(stringResource(Res.string.settings_trakt_connect)) + Text(connectLabel) } } - if (!uiState.credentialsConfigured) { + if (!credentialsConfigured) { Text( - text = stringResource(Res.string.settings_trakt_missing_credentials), + text = missingCredentialsMessage, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) @@ -807,19 +949,52 @@ private fun TraktConnectionCard( } } - uiState.statusMessage?.takeIf { it.isNotBlank() }?.let { message -> + if (!websiteLabel.isNullOrBlank() && !websiteUrl.isNullOrBlank()) { + TextButton(onClick = { openUrl(websiteUrl) }) { + Text(websiteLabel) + } + } + statusMessage?.takeIf { it.isNotBlank() }?.let { message -> Text( text = message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - uiState.errorMessage?.takeIf { it.isNotBlank() }?.let { message -> + errorMessage?.takeIf { it.isNotBlank() }?.let { message -> Text( text = message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) } + browserError?.let { + Text( + text = failedOpenBrowserMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } } } + +@Composable +private fun simklErrorMessage(error: SimklAuthError?): String? = + when (error) { + null, SimklAuthError.MISSING_CLIENT_ID -> null + SimklAuthError.INVALID_CALLBACK, + SimklAuthError.INVALID_CALLBACK_STATE + -> stringResource(Res.string.settings_simkl_invalid_callback) + + SimklAuthError.AUTHORIZATION_EXPIRED -> + stringResource(Res.string.settings_simkl_authorization_expired) + + SimklAuthError.TOKEN_EXCHANGE_FAILED, + SimklAuthError.INVALID_TOKEN_RESPONSE + -> stringResource(Res.string.settings_simkl_sign_in_failed) + + SimklAuthError.AUTHORIZATION_REVOKED -> + stringResource(Res.string.settings_simkl_authorization_revoked) + } + +private const val SIMKL_WEBSITE_URL = "https://simkl.com" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingSettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingSettings.kt new file mode 100644 index 00000000..211ec9c7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingSettings.kt @@ -0,0 +1,32 @@ +package com.nuvio.app.features.tracking + +import com.nuvio.app.features.library.LibrarySourceMode +import com.nuvio.app.features.trakt.MoreLikeThisSourcePreference +import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.trakt.TraktSettingsUiState +import kotlinx.coroutines.flow.StateFlow + +/** + * Provider-neutral entry point for tracking source preferences. + * + * The serialized profile payload intentionally stays in the existing Trakt settings store so + * current installations keep their choices. New application code should depend on this facade; + * the persistence implementation can then be migrated without leaking a provider name again. + */ +typealias TrackingSettingsUiState = TraktSettingsUiState + +object TrackingSettingsRepository { + val uiState: StateFlow + get() = TraktSettingsRepository.uiState + + fun ensureLoaded() = TraktSettingsRepository.ensureLoaded() + + fun setLibrarySourceMode(source: LibrarySourceMode) = + TraktSettingsRepository.setLibrarySourceMode(source) + + fun setContinueWatchingDaysCap(days: Int) = + TraktSettingsRepository.setContinueWatchingDaysCap(days) + + fun setMoreLikeThisSource(source: MoreLikeThisSourcePreference) = + TraktSettingsRepository.setMoreLikeThisSource(source) +} From 410a9d4c4bd16dfebbcf9de2ec3e9869ed2fb14a Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:25:32 +0530 Subject: [PATCH 10/59] refactor(tracking): register library read providers --- .../composeResources/values/strings.xml | 3 + .../commonMain/kotlin/com/nuvio/app/App.kt | 14 +- .../tracking/TrackingProviderBootstrap.kt | 5 + ...rDialog.kt => TrackingListPickerDialog.kt} | 14 +- .../app/features/details/MetaDetailsScreen.kt | 10 +- .../app/features/library/LibraryRepository.kt | 384 +++++------------- .../simkl/SimklApplicationAdapters.kt | 75 ++++ .../app/features/tracking/TrackingProvider.kt | 18 + .../app/features/tracking/TrackingReads.kt | 53 +++ .../trakt/TraktTrackingLibraryProvider.kt | 86 ++++ .../features/library/LibraryRepositoryTest.kt | 12 +- 11 files changed, 372 insertions(+), 302 deletions(-) rename composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/{TraktListPickerDialog.kt => TrackingListPickerDialog.kt} (95%) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 3d6b89d9..b170f2b4 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -515,6 +515,8 @@ Licensed under the Apache License, Version 2.0. Loading your Trakt lists… Choose where to save this title on Trakt + Loading lists… + Choose where to save this title Donate Go to details Remove @@ -1442,6 +1444,7 @@ Unable to play trailer Failed to load Trakt lists Failed to update Trakt lists + Failed to update tracking lists %1$s • %2$s Update check failed Download failed diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index a50dee88..38f74df3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -117,7 +117,7 @@ import com.nuvio.app.core.ui.NuvioToastHost import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.core.ui.NuvioFloatingPrompt import com.nuvio.app.core.ui.ProfileMeshBackground -import com.nuvio.app.core.ui.TraktListPickerDialog +import com.nuvio.app.core.ui.TrackingListPickerDialog import com.nuvio.app.core.ui.NuvioTheme import com.nuvio.app.core.ui.NuvioTokens import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding @@ -224,7 +224,7 @@ import com.nuvio.app.features.tracking.TrackingScrobbleAction import com.nuvio.app.features.tracking.TrackingScrobbleCoordinator import com.nuvio.app.features.tracking.TrackingScrobbleEvent import com.nuvio.app.features.tracking.buildTrackingMediaReference -import com.nuvio.app.features.trakt.TraktListTab +import com.nuvio.app.features.tracking.TrackingLibraryTab import com.nuvio.app.features.updater.AppUpdaterHost import com.nuvio.app.features.updater.AppUpdaterPlatform import com.nuvio.app.features.updater.rememberAppUpdaterController @@ -814,7 +814,7 @@ private fun MainAppContent( var showLibraryListPicker by remember { mutableStateOf(false) } var pickerItem by remember { mutableStateOf(null) } var pickerTitle by remember { mutableStateOf("") } - var pickerTabs by remember { mutableStateOf>(emptyList()) } + var pickerTabs by remember { mutableStateOf>(emptyList()) } var pickerMembership by remember { mutableStateOf>(emptyMap()) } var pickerPending by remember { mutableStateOf(false) } var pickerError by remember { mutableStateOf(null) } @@ -3341,7 +3341,7 @@ private fun MainAppContent( } }.onFailure { error -> NuvioToastController.show( - error.message ?: getString(Res.string.trakt_lists_update_failed), + error.message ?: getString(Res.string.tracking_lists_update_failed), ) } } @@ -3491,7 +3491,7 @@ private fun MainAppContent( }, ) - TraktListPickerDialog( + TrackingListPickerDialog( visible = showLibraryListPicker, title = pickerTitle, tabs = pickerTabs, @@ -3511,7 +3511,7 @@ private fun MainAppContent( } }, onSave = { - val item = pickerItem ?: return@TraktListPickerDialog + val item = pickerItem ?: return@TrackingListPickerDialog coroutineScope.launch { pickerPending = true pickerError = null @@ -3525,7 +3525,7 @@ private fun MainAppContent( pickerItem = null pickerError = null }.onFailure { error -> - pickerError = error.message ?: getString(Res.string.trakt_lists_update_failed) + pickerError = error.message ?: getString(Res.string.tracking_lists_update_failed) } pickerPending = false } 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 34a9f27a..7a9a39f3 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 @@ -4,9 +4,12 @@ import com.nuvio.app.features.simkl.SimklAuthRepository import com.nuvio.app.features.simkl.SimklMutationRepository import com.nuvio.app.features.simkl.SimklLibraryRepository import com.nuvio.app.features.simkl.SimklProgressRepository +import com.nuvio.app.features.simkl.SimklTrackingLibraryProvider import com.nuvio.app.features.simkl.SimklSyncRepository +import com.nuvio.app.features.tracking.TrackingProviderRegistry import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktScrobbleRepository +import com.nuvio.app.features.trakt.TraktTrackingLibraryProvider fun ensureTrackingProvidersRegistered() { TraktAuthRepository.descriptor @@ -16,4 +19,6 @@ fun ensureTrackingProvidersRegistered() { SimklLibraryRepository.uiState SimklProgressRepository.uiState SimklMutationRepository.ensureRegistered() + TrackingProviderRegistry.registerLibraryProvider(TraktTrackingLibraryProvider) + TrackingProviderRegistry.registerLibraryProvider(SimklTrackingLibraryProvider) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TraktListPickerDialog.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TrackingListPickerDialog.kt similarity index 95% rename from composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TraktListPickerDialog.kt rename to composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TrackingListPickerDialog.kt index 15566135..135ee951 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TraktListPickerDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TrackingListPickerDialog.kt @@ -25,20 +25,20 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import com.nuvio.app.features.trakt.TraktListTab +import com.nuvio.app.features.tracking.TrackingLibraryTab import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_cancel import nuvio.composeapp.generated.resources.action_save -import nuvio.composeapp.generated.resources.compose_trakt_list_picker_loading -import nuvio.composeapp.generated.resources.compose_trakt_list_picker_subtitle +import nuvio.composeapp.generated.resources.compose_tracking_list_picker_loading +import nuvio.composeapp.generated.resources.compose_tracking_list_picker_subtitle import org.jetbrains.compose.resources.stringResource @OptIn(ExperimentalMaterial3Api::class) @Composable -fun TraktListPickerDialog( +fun TrackingListPickerDialog( visible: Boolean, title: String, - tabs: List, + tabs: List, membership: Map, isPending: Boolean, errorMessage: String?, @@ -67,7 +67,7 @@ fun TraktListPickerDialog( color = tokens.colors.textPrimary, ) Text( - text = stringResource(Res.string.compose_trakt_list_picker_subtitle), + text = stringResource(Res.string.compose_tracking_list_picker_subtitle), style = MaterialTheme.typography.bodyMedium, color = tokens.colors.textMuted, ) @@ -95,7 +95,7 @@ fun TraktListPickerDialog( modifier = Modifier.size(tokens.icons.lg), ) Text( - text = stringResource(Res.string.compose_trakt_list_picker_loading), + text = stringResource(Res.string.compose_tracking_list_picker_loading), style = MaterialTheme.typography.bodyMedium, color = tokens.colors.textMuted, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 87380ec3..c3b26978 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -82,7 +82,7 @@ import com.nuvio.app.core.ui.NuvioPosterZoomActionOverlay import com.nuvio.app.core.ui.PosterZoomAnchor import com.nuvio.app.core.ui.PosterZoomAnchorHolder import com.nuvio.app.core.ui.PosterZoomOverlayAction -import com.nuvio.app.core.ui.TraktListPickerDialog +import com.nuvio.app.core.ui.TrackingListPickerDialog import com.nuvio.app.core.ui.nuvioSafeBottomPadding import com.nuvio.app.core.ui.rememberHeroStretchState import dev.chrisbanes.haze.hazeSource @@ -115,7 +115,7 @@ import com.nuvio.app.features.trakt.TraktCommentReview import com.nuvio.app.features.trakt.TraktCommentsRepository import com.nuvio.app.features.trakt.TraktCommentsSettings import com.nuvio.app.features.trakt.TraktConnectionMode -import com.nuvio.app.features.trakt.TraktListTab +import com.nuvio.app.features.tracking.TrackingLibraryTab import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.trailer.TrailerPlaybackResolver @@ -213,7 +213,7 @@ fun MetaDetailsScreen( var selectedComment by remember(type, id) { mutableStateOf(null) } val detailsScope = rememberCoroutineScope() var showLibraryListPicker by remember(type, id) { mutableStateOf(false) } - var pickerTabs by remember(type, id) { mutableStateOf>(emptyList()) } + var pickerTabs by remember(type, id) { mutableStateOf>(emptyList()) } var pickerMembership by remember(type, id) { mutableStateOf>(emptyMap()) } var pickerPending by remember(type, id) { mutableStateOf(false) } var pickerError by remember(type, id) { mutableStateOf(null) } @@ -1212,7 +1212,7 @@ fun MetaDetailsScreen( ) } - TraktListPickerDialog( + TrackingListPickerDialog( visible = showLibraryListPicker, title = meta.name, tabs = pickerTabs, @@ -1241,7 +1241,7 @@ fun MetaDetailsScreen( }.onSuccess { showLibraryListPicker = false }.onFailure { error -> - pickerError = error.message ?: getString(Res.string.trakt_lists_update_failed) + pickerError = error.message ?: getString(Res.string.tracking_lists_update_failed) } pickerPending = false } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt index 3aa17583..441c21f3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt @@ -9,18 +9,13 @@ import com.nuvio.app.core.tracking.ensureTrackingProvidersRegistered import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.profiles.ProfileRepository -import com.nuvio.app.features.simkl.SIMKL_WATCHLIST_KEY -import com.nuvio.app.features.simkl.SIMKL_WATCHLIST_TITLE -import com.nuvio.app.features.simkl.SimklLibraryRepository -import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.TrackingLibraryProvider +import com.nuvio.app.features.tracking.TrackingLibraryTab +import com.nuvio.app.features.tracking.TrackingLibraryTabKind import com.nuvio.app.features.tracking.TrackingProviderRegistry +import com.nuvio.app.features.tracking.TrackingSettingsRepository import com.nuvio.app.features.tracking.effectiveLibrarySourceMode as resolveEffectiveLibrarySourceMode -import com.nuvio.app.features.trakt.TraktAuthRepository -import com.nuvio.app.features.trakt.TraktLibraryRepository -import com.nuvio.app.features.trakt.TraktListTab -import com.nuvio.app.features.trakt.TraktListType -import com.nuvio.app.features.trakt.TraktMembershipChanges -import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.tracking.providerId import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.rpc import kotlinx.atomicfu.locks.SynchronizedObject @@ -52,7 +47,7 @@ import kotlinx.serialization.json.put import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.library_local_tab_title import nuvio.composeapp.generated.resources.library_other -import nuvio.composeapp.generated.resources.trakt_lists_update_failed +import nuvio.composeapp.generated.resources.tracking_lists_update_failed import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.getString @@ -97,55 +92,34 @@ object LibraryRepository { private val lastPersistedContentRevisionByProfile = mutableMapOf() init { + ensureTrackingProvidersRegistered() syncScope.launch { - TrackingProviderRegistry.connectedProviderIds.collectLatest { connectedProviderIds -> - if (TrackingProviderId.TRAKT in connectedProviderIds) { - TraktLibraryRepository.preloadListTabsAsync() - if (effectiveLibrarySourceMode() == LibrarySourceMode.TRAKT) { - runCatching { TraktLibraryRepository.refreshNow() } - .onFailure { log.e(it) { "Failed to refresh Trakt library after auth change" } } - } - } - if ( - TrackingProviderId.SIMKL in connectedProviderIds && - effectiveLibrarySourceMode() == LibrarySourceMode.SIMKL - ) { - runCatching { SimklLibraryRepository.refreshNow() } - .onFailure { log.e(it) { "Failed to refresh Simkl library after auth change" } } + TrackingProviderRegistry.connectedProviderIds.collectLatest { + TrackingProviderRegistry.connectedLibraryProviders().forEach(TrackingLibraryProvider::prepare) + activeLibraryProvider()?.let { provider -> + refreshLibraryProvider(provider, "authentication change") } publish() } } syncScope.launch { - TraktSettingsRepository.uiState + TrackingSettingsRepository.uiState .map { it.librarySourceMode } .distinctUntilChanged() - .collectLatest { source -> - when (resolveEffectiveLibrarySourceMode(source, TrackingProviderRegistry::isAuthenticated)) { - LibrarySourceMode.TRAKT -> { - TraktLibraryRepository.preloadListTabsAsync() - publish() - refreshTraktLibraryAsync() - } - LibrarySourceMode.SIMKL -> { - publish() - refreshSimklLibraryAsync() - } - LibrarySourceMode.LOCAL -> publish() + .collectLatest { + publish() + activeLibraryProvider()?.let { provider -> + provider.prepare() + refreshLibraryProviderAsync(provider) } } } - syncScope.launch { - TraktLibraryRepository.uiState.collectLatest { - if (TraktAuthRepository.isAuthenticated.value) { - publish() - } - } - } - syncScope.launch { - SimklLibraryRepository.uiState.collectLatest { - if (TrackingProviderRegistry.isAuthenticated(TrackingProviderId.SIMKL)) { - publish() + TrackingProviderRegistry.libraryProviders().forEach { provider -> + syncScope.launch { + provider.changes.collectLatest { + if (TrackingProviderRegistry.isAuthenticated(provider.providerId)) { + publish() + } } } } @@ -154,47 +128,32 @@ object LibraryRepository { fun ensureLoaded() { ensureTrackingProvidersRegistered() TrackingProviderRegistry.ensureLoaded() - TraktSettingsRepository.ensureLoaded() - TraktLibraryRepository.ensureLoaded() - SimklLibraryRepository.ensureLoaded() + TrackingSettingsRepository.ensureLoaded() + TrackingProviderRegistry.libraryProviders().forEach(TrackingLibraryProvider::ensureLoaded) while (true) { val activeProfileId = ProfileRepository.activeProfileId val snapshot = localState.snapshot() if (snapshot.hasLoaded && snapshot.token.profileId == activeProfileId) break loadFromDisk(activeProfileId) } - if (TrackingProviderRegistry.isAuthenticated(TrackingProviderId.TRAKT)) { - TraktLibraryRepository.preloadListTabsAsync() - if (isTraktLibrarySourceActive()) { - refreshTraktLibraryAsync() - } - } - if (isSimklLibrarySourceActive()) refreshSimklLibraryAsync() + TrackingProviderRegistry.connectedLibraryProviders().forEach(TrackingLibraryProvider::prepare) + activeLibraryProvider()?.let(::refreshLibraryProviderAsync) } fun onProfileChanged(profileId: Int) { val current = localState.snapshot() if (profileId == current.token.profileId && current.hasLoaded) return - TraktSettingsRepository.onProfileChanged() if (!loadFromDisk(profileId)) return - TraktAuthRepository.onProfileChanged() - TraktLibraryRepository.onProfileChanged() - if (TraktAuthRepository.isAuthenticated.value) { - TraktLibraryRepository.preloadListTabsAsync() - if (isTraktLibrarySourceActive()) { - refreshTraktLibraryAsync() - } - } - SimklLibraryRepository.ensureLoaded() - if (isSimklLibrarySourceActive()) refreshSimklLibraryAsync() + TrackingProviderRegistry.libraryProviders().forEach(TrackingLibraryProvider::onProfileChanged) + TrackingProviderRegistry.connectedLibraryProviders().forEach(TrackingLibraryProvider::prepare) + activeLibraryProvider()?.let(::refreshLibraryProviderAsync) } fun clearLocalState() { val transition = synchronized(loadLock) { localState.reset() } transition.detachedPushJob?.cancel() - TraktAuthRepository.clearLocalState() - TraktLibraryRepository.clearLocalState() + TrackingProviderRegistry.libraryProviders().forEach(TrackingLibraryProvider::clearLocalState) _uiState.value = LibraryUiState() } @@ -253,32 +212,11 @@ object LibraryRepository { return } - when (effectiveLibrarySourceMode()) { - LibrarySourceMode.TRAKT -> { - try { - TraktLibraryRepository.refreshNow() - } catch (error: CancellationException) { - throw error - } catch (error: Throwable) { - log.e(error) { "Failed to pull Trakt library" } - } - if (!isActiveOperation(operationToken)) return - publish() - return - } - LibrarySourceMode.SIMKL -> { - try { - SimklLibraryRepository.refreshNow() - } catch (error: CancellationException) { - throw error - } catch (error: Throwable) { - log.e(error) { "Failed to pull Simkl library" } - } - if (!isActiveOperation(operationToken)) return - publish() - return - } - LibrarySourceMode.LOCAL -> Unit + activeLibraryProvider()?.let { provider -> + refreshLibraryProvider(provider, "explicit pull") + if (!isActiveOperation(operationToken)) return + publish() + return } nuvioPullMutex.withLock { @@ -322,16 +260,20 @@ object LibraryRepository { fun toggleSaved(item: LibraryItem) { ensureLoaded() - if (isTraktLibrarySourceActive()) { + activeLibraryProvider()?.let { provider -> val profileId = localState.snapshot().token.profileId - log.i { "toggleSaved routed to Trakt library source item=${item.id} type=${item.type} profile=$profileId" } + log.i { + "toggleSaved routed to ${provider.providerId.storageId} library source " + + "item=${item.id} type=${item.type} profile=$profileId" + } syncScope.launch { - runCatching { TraktLibraryRepository.toggleWatchlist(item) } - .onFailure { e -> - log.e(e) { "Failed to toggle Trakt watchlist" } + runCatching { provider.toggleDefaultMembership(profileId, item) } + .onFailure { error -> + if (error is CancellationException) throw error + log.e(error) { "Failed to toggle ${provider.providerId.storageId} default library membership" } NuvioToastController.show( - e.message?.takeIf { it.isNotBlank() } - ?: getString(Res.string.trakt_lists_update_failed), + error.message?.takeIf(String::isNotBlank) + ?: getString(Res.string.tracking_lists_update_failed), ) } publish() @@ -339,29 +281,6 @@ object LibraryRepository { return } - if (isSimklLibrarySourceActive()) { - val profileId = localState.snapshot().token.profileId - val isCurrentlySaved = SimklLibraryRepository.isInWatchlist(item.id, item.type) - log.i { - "toggleSaved routed to Simkl library source item=${item.id} type=${item.type} profile=$profileId" - } - syncScope.launch { - runCatching { - SimklLibraryRepository.setWatchlistMembership( - profileId = profileId, - item = item, - isMember = !isCurrentlySaved, - ) - }.onFailure { error -> - if (error is CancellationException) throw error - log.e(error) { "Failed to toggle Simkl watchlist" } - NuvioToastController.show(error.message ?: "Unable to update Simkl Watchlist") - } - publish() - } - return - } - val result = localState.toggle( item.copy(savedAtEpochMs = LibraryClock.nowEpochMs()), ) @@ -422,20 +341,7 @@ object LibraryRepository { fun isSaved(id: String, type: String? = null): Boolean { ensureLoaded() - if (isTraktLibrarySourceActive()) { - if (type != null) { - return TraktLibraryRepository.isInAnyList(id, type) - } - val entry = TraktLibraryRepository.uiState.value.allItems.firstOrNull { it.id == id } - if (entry != null) { - return TraktLibraryRepository.isInAnyList(entry.id, entry.type) - } - return false - } - - if (isSimklLibrarySourceActive()) { - return SimklLibraryRepository.isInWatchlist(id, type) - } + activeLibraryProvider()?.let { provider -> return provider.contains(id, type) } return if (type != null) { localState.contains(id, type) @@ -447,44 +353,25 @@ object LibraryRepository { fun savedItem(id: String): LibraryItem? { ensureLoaded() - if (isTraktLibrarySourceActive()) { - return TraktLibraryRepository.uiState.value.allItems.firstOrNull { it.id == id } - } - - if (isSimklLibrarySourceActive()) { - return SimklLibraryRepository.uiState.value.items.firstOrNull { it.id == id } - } + activeLibraryProvider()?.let { provider -> return provider.find(id) } return localState.findById(id) } - fun libraryListTabs(): List { - val traktTabs = if (TrackingProviderRegistry.isAuthenticated(TrackingProviderId.TRAKT)) { - TraktLibraryRepository.currentListTabs() - } else { - emptyList() - } - val simklTabs = if (TrackingProviderRegistry.isAuthenticated(TrackingProviderId.SIMKL)) { - listOf(simklLibraryListTab()) - } else { - emptyList() - } - return libraryTabsWithLocal(traktTabs + simklTabs) - } - - fun traktListTabs(): List = libraryListTabs() + fun libraryListTabs(): List = + libraryTabsWithLocal( + TrackingProviderRegistry.connectedLibraryProviders() + .flatMap { provider -> provider.snapshot().tabs }, + ) suspend fun getMembershipSnapshot(item: LibraryItem): Map { ensureLoaded() val inLocal = localState.contains(item.id, item.type) val memberships = linkedMapOf() - if (TrackingProviderRegistry.isAuthenticated(TrackingProviderId.TRAKT)) { - memberships += TraktLibraryRepository.getMembershipSnapshot(item).listMembership + TrackingProviderRegistry.connectedLibraryProviders().forEach { provider -> + memberships += provider.membership(item) } - if (TrackingProviderRegistry.isAuthenticated(TrackingProviderId.SIMKL)) { - memberships[SIMKL_WATCHLIST_KEY] = SimklLibraryRepository.isInWatchlist(item.id, item.type) - } - return libraryMembershipWithLocal(inLocal = inLocal, traktMembership = memberships) + return libraryMembershipWithLocal(inLocal = inLocal, providerMembership = memberships) } suspend fun applyMembershipChanges(item: LibraryItem, desiredMembership: Map) { @@ -506,38 +393,21 @@ object LibraryRepository { } var firstFailure: Throwable? = null - if (TrackingProviderRegistry.isAuthenticated(TrackingProviderId.TRAKT)) { - val traktListKeys = TraktLibraryRepository.currentListTabs().mapTo(mutableSetOf(), TraktListTab::key) - val traktMembership = desiredMembership.filterKeys { key -> key in traktListKeys } - if (traktMembership.isNotEmpty()) { + TrackingProviderRegistry.connectedLibraryProviders().forEach { provider -> + val providerListKeys = provider.snapshot().tabs.mapTo(mutableSetOf(), TrackingLibraryTab::key) + val providerMembership = desiredMembership.filterKeys(providerListKeys::contains) + if (providerMembership.isNotEmpty()) { try { - TraktLibraryRepository.applyMembershipChanges( - item = item, - changes = TraktMembershipChanges(desiredMembership = traktMembership), - ) - } catch (error: CancellationException) { - throw error - } catch (error: Throwable) { - firstFailure = error - log.e(error) { "Failed to update Trakt library membership" } - } - } - } - if (TrackingProviderRegistry.isAuthenticated(TrackingProviderId.SIMKL)) { - val desired = desiredMembership[SIMKL_WATCHLIST_KEY] == true - val current = SimklLibraryRepository.isInWatchlist(item.id, item.type) - if (desired != current) { - try { - SimklLibraryRepository.setWatchlistMembership( + provider.applyMembership( profileId = profileId, item = item, - isMember = desired, + desiredMembership = providerMembership, ) } catch (error: CancellationException) { throw error } catch (error: Throwable) { if (firstFailure == null) firstFailure = error - log.e(error) { "Failed to update Simkl library membership" } + log.e(error) { "Failed to update ${provider.providerId.storageId} library membership" } } } } @@ -634,59 +504,21 @@ object LibraryRepository { private fun publish() { val localSnapshot = localState.snapshot() - when (effectiveLibrarySourceMode()) { - LibrarySourceMode.TRAKT -> { - val traktState = TraktLibraryRepository.uiState.value - val sections = traktState.listTabs.mapNotNull { tab -> - val listItems = traktState.entriesByList[tab.key].orEmpty() - if (listItems.isEmpty()) { - null - } else { - LibrarySection( - type = tab.key, - displayTitle = tab.title, - items = listItems, - ) - } - } - - val newUiState = LibraryUiState( - sourceMode = LibrarySourceMode.TRAKT, - items = traktState.allItems, - sections = sections, - isLoaded = traktState.hasLoaded, - isLoading = traktState.isLoading, - errorMessage = traktState.errorMessage, - ) - localState.runIfTokenCurrent(localSnapshot.token) { - _uiState.value = newUiState - } - return + val sourceMode = effectiveLibrarySourceMode() + activeLibraryProvider(sourceMode)?.let { provider -> + val providerSnapshot = provider.snapshot() + val newUiState = LibraryUiState( + sourceMode = sourceMode, + items = providerSnapshot.items, + sections = providerSnapshot.sections, + isLoaded = providerSnapshot.hasLoaded, + isLoading = providerSnapshot.isLoading, + errorMessage = providerSnapshot.errorMessage, + ) + localState.runIfTokenCurrent(localSnapshot.token) { + _uiState.value = newUiState } - LibrarySourceMode.SIMKL -> { - val simklState = SimklLibraryRepository.uiState.value - val items = simklState.items.sortedByDescending(LibraryItem::savedAtEpochMs) - val sections = listOf( - LibrarySection( - type = SIMKL_WATCHLIST_KEY, - displayTitle = SIMKL_WATCHLIST_TITLE, - items = items, - ), - ) - val newUiState = LibraryUiState( - sourceMode = LibrarySourceMode.SIMKL, - items = items, - sections = sections, - isLoaded = simklState.hasLoaded, - isLoading = simklState.isLoading, - errorMessage = simklState.errorMessage, - ) - localState.runIfTokenCurrent(localSnapshot.token) { - _uiState.value = newUiState - } - return - } - LibrarySourceMode.LOCAL -> Unit + return } val items = localSnapshot.items @@ -732,28 +564,31 @@ object LibraryRepository { } } - private fun refreshTraktLibraryAsync() { + private fun refreshLibraryProviderAsync(provider: TrackingLibraryProvider) { syncScope.launch { - runCatching { TraktLibraryRepository.refreshNow() } - .onFailure { e -> log.e(e) { "Failed to refresh Trakt library" } } + refreshLibraryProvider(provider, "background refresh") publish() } } - private fun refreshSimklLibraryAsync() { - syncScope.launch { - runCatching { SimklLibraryRepository.refreshNow() } - .onFailure { error -> - if (error is CancellationException) throw error - log.e(error) { "Failed to refresh Simkl library" } - } - publish() + private suspend fun refreshLibraryProvider( + provider: TrackingLibraryProvider, + reason: String, + ) { + try { + provider.refresh() + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.e(error) { + "Failed to refresh ${provider.providerId.storageId} library during $reason" + } } } private fun selectedLibrarySourceMode(): LibrarySourceMode { - TraktSettingsRepository.ensureLoaded() - return TraktSettingsRepository.uiState.value.librarySourceMode + TrackingSettingsRepository.ensureLoaded() + return TrackingSettingsRepository.uiState.value.librarySourceMode } private fun effectiveLibrarySourceMode(): LibrarySourceMode = @@ -762,43 +597,36 @@ object LibraryRepository { isProviderAuthenticated = TrackingProviderRegistry::isAuthenticated, ) - private fun isTraktLibrarySourceActive(): Boolean = - effectiveLibrarySourceMode() == LibrarySourceMode.TRAKT - - private fun isSimklLibrarySourceActive(): Boolean = - effectiveLibrarySourceMode() == LibrarySourceMode.SIMKL + private fun activeLibraryProvider( + sourceMode: LibrarySourceMode = effectiveLibrarySourceMode(), + ): TrackingLibraryProvider? = + sourceMode.providerId?.let(TrackingProviderRegistry::libraryProvider) } internal const val LOCAL_LIBRARY_LIST_KEY = "local" private const val DEFAULT_LOCAL_LIBRARY_TAB_TITLE = "Nuvio Library" private const val DEFAULT_LIBRARY_OTHER_TITLE = "Other" -internal fun localLibraryListTab(): TraktListTab = - TraktListTab( +internal fun localLibraryListTab(): TrackingLibraryTab = + TrackingLibraryTab( key = LOCAL_LIBRARY_LIST_KEY, title = localizedStringOrDefault( resource = Res.string.library_local_tab_title, fallback = DEFAULT_LOCAL_LIBRARY_TAB_TITLE, ), - type = TraktListType.WATCHLIST, + providerId = null, + kind = TrackingLibraryTabKind.WATCHLIST, ) -internal fun simklLibraryListTab(): TraktListTab = - TraktListTab( - key = SIMKL_WATCHLIST_KEY, - title = SIMKL_WATCHLIST_TITLE, - type = TraktListType.WATCHLIST, - ) - -internal fun libraryTabsWithLocal(traktTabs: List): List = - listOf(localLibraryListTab()) + traktTabs +internal fun libraryTabsWithLocal(providerTabs: List): List = + listOf(localLibraryListTab()) + providerTabs internal fun libraryMembershipWithLocal( inLocal: Boolean, - traktMembership: Map = emptyMap(), + providerMembership: Map = emptyMap(), ): Map = linkedMapOf(LOCAL_LIBRARY_LIST_KEY to inLocal).apply { - putAll(traktMembership) + putAll(providerMembership) } internal fun libraryMembershipWithRemovedList( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt index b327b8b3..f9226a59 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt @@ -2,9 +2,15 @@ package com.nuvio.app.features.simkl import co.touchlab.kermit.Logger import com.nuvio.app.features.library.LibraryItem +import com.nuvio.app.features.library.LibrarySection import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.tracking.TrackingHistoryItem +import com.nuvio.app.features.tracking.TrackingLibraryProvider +import com.nuvio.app.features.tracking.TrackingLibrarySnapshot +import com.nuvio.app.features.tracking.TrackingLibraryTab +import com.nuvio.app.features.tracking.TrackingLibraryTabKind import com.nuvio.app.features.tracking.TrackingListStatus +import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watching.sync.WatchedSyncAdapter import com.nuvio.app.features.watchprogress.WatchProgressEntry @@ -16,6 +22,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch data class SimklLibraryUiState( @@ -96,6 +104,73 @@ object SimklLibraryRepository { } } +object SimklTrackingLibraryProvider : TrackingLibraryProvider { + override val providerId: TrackingProviderId = TrackingProviderId.SIMKL + override val changes: Flow = SimklLibraryRepository.uiState.map { Unit } + + override fun ensureLoaded() = SimklLibraryRepository.ensureLoaded() + + override suspend fun refresh() = SimklLibraryRepository.refreshNow() + + override fun snapshot(): TrackingLibrarySnapshot { + val state = SimklLibraryRepository.uiState.value + val items = state.items.sortedByDescending(LibraryItem::savedAtEpochMs) + return TrackingLibrarySnapshot( + items = items, + sections = listOf( + LibrarySection( + type = SIMKL_WATCHLIST_KEY, + displayTitle = SIMKL_WATCHLIST_TITLE, + items = items, + ), + ), + tabs = listOf( + TrackingLibraryTab( + key = SIMKL_WATCHLIST_KEY, + title = SIMKL_WATCHLIST_TITLE, + providerId = TrackingProviderId.SIMKL, + kind = TrackingLibraryTabKind.WATCHLIST, + ), + ), + hasLoaded = state.hasLoaded, + isLoading = state.isLoading, + errorMessage = state.errorMessage, + ) + } + + override fun contains(contentId: String, contentType: String?): Boolean = + SimklLibraryRepository.isInWatchlist(contentId, contentType) + + override fun find(contentId: String): LibraryItem? = + SimklLibraryRepository.uiState.value.items.firstOrNull { item -> item.id == contentId } + + override suspend fun membership(item: LibraryItem): Map = + mapOf(SIMKL_WATCHLIST_KEY to SimklLibraryRepository.isInWatchlist(item.id, item.type)) + + override suspend fun applyMembership( + profileId: Int, + item: LibraryItem, + desiredMembership: Map, + ) { + val desired = desiredMembership[SIMKL_WATCHLIST_KEY] == true + if (desired != SimklLibraryRepository.isInWatchlist(item.id, item.type)) { + SimklLibraryRepository.setWatchlistMembership( + profileId = profileId, + item = item, + isMember = desired, + ) + } + } + + override suspend fun toggleDefaultMembership(profileId: Int, item: LibraryItem) { + SimklLibraryRepository.setWatchlistMembership( + profileId = profileId, + item = item, + isMember = !SimklLibraryRepository.isInWatchlist(item.id, item.type), + ) + } +} + object SimklWatchedSyncAdapter : WatchedSyncAdapter { override suspend fun pull(profileId: Int, pageSize: Int): List { if (profileId != ProfileRepository.activeProfileId) return emptyList() 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 4c0d0619..1b6c1538 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 @@ -74,6 +74,7 @@ object TrackingProviderRegistry { private val listWriters = mutableMapOf() private val historyWriters = mutableMapOf() private val scrobblers = mutableMapOf() + private val libraryProviders = mutableMapOf() private val _connectedProviderIds = MutableStateFlow>(emptySet()) val connectedProviderIds: StateFlow> = _connectedProviderIds.asStateFlow() @@ -113,6 +114,10 @@ object TrackingProviderRegistry { scrobblers[scrobbler.providerId] = scrobbler } + fun registerLibraryProvider(provider: TrackingLibraryProvider) = synchronized(lock) { + libraryProviders[provider.providerId] = provider + } + fun authProvider(id: TrackingProviderId): TrackingAuthProvider? = synchronized(lock) { authProviders[id] } @@ -144,6 +149,16 @@ object TrackingProviderRegistry { scrobblers[id] } + fun libraryProvider(id: TrackingProviderId): TrackingLibraryProvider? = synchronized(lock) { + libraryProviders[id] + } + + fun libraryProviders(): List = synchronized(lock) { + libraryProviders.entries + .sortedBy { (id, _) -> id.ordinal } + .map { (_, provider) -> provider } + } + fun connectedListWriters(): List = connectedPorts(listWriters, TrackingCapability.LIBRARY_WRITE) @@ -153,6 +168,9 @@ object TrackingProviderRegistry { fun connectedScrobblers(): List = connectedPorts(scrobblers, TrackingCapability.SCROBBLE) + fun connectedLibraryProviders(): List = + connectedPorts(libraryProviders, TrackingCapability.LIBRARY_READ) + fun handleAuthCallback(url: String): Boolean = providersWith(TrackingCapability.AUTHENTICATION) .any { provider -> provider.handleAuthCallback(url) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt new file mode 100644 index 00000000..55041ec1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt @@ -0,0 +1,53 @@ +package com.nuvio.app.features.tracking + +import com.nuvio.app.features.library.LibraryItem +import com.nuvio.app.features.library.LibrarySection +import kotlinx.coroutines.flow.Flow + +enum class TrackingLibraryTabKind { + WATCHLIST, + PERSONAL, +} + +data class TrackingLibraryTab( + val key: String, + val title: String, + val providerId: TrackingProviderId?, + val kind: TrackingLibraryTabKind, +) + +data class TrackingLibrarySnapshot( + val items: List = emptyList(), + val sections: List = emptyList(), + val tabs: List = emptyList(), + val hasLoaded: Boolean = false, + val isLoading: Boolean = false, + val errorMessage: String? = null, +) + +/** + * Provider-owned projection of remote library state into application models. + * + * Application repositories consume this port through [TrackingProviderRegistry]; provider DTOs, + * list semantics, cache policy, and mutation details stay inside the provider package. + */ +interface TrackingLibraryProvider { + val providerId: TrackingProviderId + val changes: Flow + + fun ensureLoaded() + fun prepare() = Unit + fun onProfileChanged() = Unit + fun clearLocalState() = Unit + suspend fun refresh() + fun snapshot(): TrackingLibrarySnapshot + fun contains(contentId: String, contentType: String? = null): Boolean + fun find(contentId: String): LibraryItem? + suspend fun membership(item: LibraryItem): Map + suspend fun applyMembership( + profileId: Int, + item: LibraryItem, + desiredMembership: Map, + ) + suspend fun toggleDefaultMembership(profileId: Int, item: LibraryItem) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt new file mode 100644 index 00000000..ecca9391 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt @@ -0,0 +1,86 @@ +package com.nuvio.app.features.trakt + +import com.nuvio.app.features.library.LibraryItem +import com.nuvio.app.features.library.LibrarySection +import com.nuvio.app.features.tracking.TrackingLibraryProvider +import com.nuvio.app.features.tracking.TrackingLibrarySnapshot +import com.nuvio.app.features.tracking.TrackingLibraryTab +import com.nuvio.app.features.tracking.TrackingLibraryTabKind +import com.nuvio.app.features.tracking.TrackingProviderId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +object TraktTrackingLibraryProvider : TrackingLibraryProvider { + override val providerId: TrackingProviderId = TrackingProviderId.TRAKT + override val changes: Flow = TraktLibraryRepository.uiState.map { Unit } + + override fun ensureLoaded() = TraktLibraryRepository.ensureLoaded() + + override fun prepare() = TraktLibraryRepository.preloadListTabsAsync() + + override fun onProfileChanged() = TraktLibraryRepository.onProfileChanged() + + override fun clearLocalState() = TraktLibraryRepository.clearLocalState() + + override suspend fun refresh() = TraktLibraryRepository.refreshNow() + + override fun snapshot(): TrackingLibrarySnapshot { + val state = TraktLibraryRepository.uiState.value + return TrackingLibrarySnapshot( + items = state.allItems, + sections = state.listTabs.mapNotNull { tab -> + state.entriesByList[tab.key] + .orEmpty() + .takeIf(List::isNotEmpty) + ?.let { items -> + LibrarySection( + type = tab.key, + displayTitle = tab.title, + items = items, + ) + } + }, + tabs = state.listTabs.map(TraktListTab::toTrackingLibraryTab), + hasLoaded = state.hasLoaded, + isLoading = state.isLoading, + errorMessage = state.errorMessage, + ) + } + + override fun contains(contentId: String, contentType: String?): Boolean { + if (contentType != null) return TraktLibraryRepository.isInAnyList(contentId, contentType) + val item = find(contentId) ?: return false + return TraktLibraryRepository.isInAnyList(item.id, item.type) + } + + override fun find(contentId: String): LibraryItem? = + TraktLibraryRepository.uiState.value.allItems.firstOrNull { item -> item.id == contentId } + + override suspend fun membership(item: LibraryItem): Map = + TraktLibraryRepository.getMembershipSnapshot(item).listMembership + + override suspend fun applyMembership( + profileId: Int, + item: LibraryItem, + desiredMembership: Map, + ) { + TraktLibraryRepository.applyMembershipChanges( + item = item, + changes = TraktMembershipChanges(desiredMembership = desiredMembership), + ) + } + + override suspend fun toggleDefaultMembership(profileId: Int, item: LibraryItem) = + TraktLibraryRepository.toggleWatchlist(item) +} + +private fun TraktListTab.toTrackingLibraryTab(): TrackingLibraryTab = + TrackingLibraryTab( + key = key, + title = title, + providerId = TrackingProviderId.TRAKT, + kind = when (type) { + TraktListType.WATCHLIST -> TrackingLibraryTabKind.WATCHLIST + TraktListType.PERSONAL -> TrackingLibraryTabKind.PERSONAL + }, + ) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt index 0a5add23..5beca834 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt @@ -3,8 +3,9 @@ package com.nuvio.app.features.library import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.MetaPreview -import com.nuvio.app.features.trakt.TraktListTab -import com.nuvio.app.features.trakt.TraktListType +import com.nuvio.app.features.tracking.TrackingLibraryTab +import com.nuvio.app.features.tracking.TrackingLibraryTabKind +import com.nuvio.app.features.tracking.TrackingProviderId import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -71,10 +72,11 @@ class LibraryRepositoryTest { @Test fun `library tabs include local Nuvio library before Trakt tabs`() { - val traktTab = TraktListTab( + val traktTab = TrackingLibraryTab( key = "trakt:watchlist", title = "Watchlist", - type = TraktListType.WATCHLIST, + providerId = TrackingProviderId.TRAKT, + kind = TrackingLibraryTabKind.WATCHLIST, ) val tabs = libraryTabsWithLocal(listOf(traktTab)) @@ -87,7 +89,7 @@ class LibraryRepositoryTest { fun `library membership always includes local state before Trakt membership`() { val membership = libraryMembershipWithLocal( inLocal = true, - traktMembership = mapOf("trakt:watchlist" to false), + providerMembership = mapOf("trakt:watchlist" to false), ) assertEquals( From 617bfe298e9d5b53ebb8ab0dcc31fed8c1212fde Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:31:09 +0530 Subject: [PATCH 11/59] refactor(tracking): register watched read providers --- .../tracking/TrackingProviderBootstrap.kt | 4 + .../simkl/SimklApplicationAdapters.kt | 5 +- .../app/features/tracking/TrackingProvider.kt | 12 + .../app/features/tracking/TrackingReads.kt | 6 + .../app/features/tracking/TrackingSettings.kt | 3 + .../app/features/watched/WatchedRepository.kt | 273 +++++++----------- .../watching/sync/TraktWatchedSyncAdapter.kt | 5 +- .../WatchProgressSourceCoordinator.kt | 10 +- .../app/features/watched/WatchedModelsTest.kt | 25 +- .../features/watched/WatchedRepositoryTest.kt | 58 ++-- 10 files changed, 180 insertions(+), 221 deletions(-) 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 7a9a39f3..a1226d1c 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 @@ -5,11 +5,13 @@ import com.nuvio.app.features.simkl.SimklMutationRepository import com.nuvio.app.features.simkl.SimklLibraryRepository import com.nuvio.app.features.simkl.SimklProgressRepository import com.nuvio.app.features.simkl.SimklTrackingLibraryProvider +import com.nuvio.app.features.simkl.SimklWatchedSyncAdapter import com.nuvio.app.features.simkl.SimklSyncRepository import com.nuvio.app.features.tracking.TrackingProviderRegistry import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktScrobbleRepository import com.nuvio.app.features.trakt.TraktTrackingLibraryProvider +import com.nuvio.app.features.watching.sync.TraktWatchedSyncAdapter fun ensureTrackingProvidersRegistered() { TraktAuthRepository.descriptor @@ -21,4 +23,6 @@ fun ensureTrackingProvidersRegistered() { SimklMutationRepository.ensureRegistered() TrackingProviderRegistry.registerLibraryProvider(TraktTrackingLibraryProvider) TrackingProviderRegistry.registerLibraryProvider(SimklTrackingLibraryProvider) + TrackingProviderRegistry.registerWatchedProvider(TraktWatchedSyncAdapter) + TrackingProviderRegistry.registerWatchedProvider(SimklWatchedSyncAdapter) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt index f9226a59..6f5721f2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt @@ -11,8 +11,8 @@ import com.nuvio.app.features.tracking.TrackingLibraryTab import com.nuvio.app.features.tracking.TrackingLibraryTabKind import com.nuvio.app.features.tracking.TrackingListStatus import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.TrackingWatchedProvider import com.nuvio.app.features.watched.WatchedItem -import com.nuvio.app.features.watching.sync.WatchedSyncAdapter import com.nuvio.app.features.watchprogress.WatchProgressEntry import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -171,7 +171,8 @@ object SimklTrackingLibraryProvider : TrackingLibraryProvider { } } -object SimklWatchedSyncAdapter : WatchedSyncAdapter { +object SimklWatchedSyncAdapter : TrackingWatchedProvider { + override val providerId: TrackingProviderId = TrackingProviderId.SIMKL override suspend fun pull(profileId: Int, pageSize: Int): List { if (profileId != ProfileRepository.activeProfileId) return emptyList() SimklSyncRepository.ensureFresh() 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 1b6c1538..4ae2d43a 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 @@ -75,6 +75,7 @@ object TrackingProviderRegistry { private val historyWriters = mutableMapOf() private val scrobblers = mutableMapOf() private val libraryProviders = mutableMapOf() + private val watchedProviders = mutableMapOf() private val _connectedProviderIds = MutableStateFlow>(emptySet()) val connectedProviderIds: StateFlow> = _connectedProviderIds.asStateFlow() @@ -118,6 +119,10 @@ object TrackingProviderRegistry { libraryProviders[provider.providerId] = provider } + fun registerWatchedProvider(provider: TrackingWatchedProvider) = synchronized(lock) { + watchedProviders[provider.providerId] = provider + } + fun authProvider(id: TrackingProviderId): TrackingAuthProvider? = synchronized(lock) { authProviders[id] } @@ -159,6 +164,10 @@ object TrackingProviderRegistry { .map { (_, provider) -> provider } } + fun watchedProvider(id: TrackingProviderId): TrackingWatchedProvider? = synchronized(lock) { + watchedProviders[id] + } + fun connectedListWriters(): List = connectedPorts(listWriters, TrackingCapability.LIBRARY_WRITE) @@ -171,6 +180,9 @@ object TrackingProviderRegistry { fun connectedLibraryProviders(): List = connectedPorts(libraryProviders, TrackingCapability.LIBRARY_READ) + fun connectedWatchedProviders(): List = + connectedPorts(watchedProviders, TrackingCapability.WATCHED_READ) + fun handleAuthCallback(url: String): Boolean = providersWith(TrackingCapability.AUTHENTICATION) .any { provider -> provider.handleAuthCallback(url) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt index 55041ec1..739ae3de 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.tracking import com.nuvio.app.features.library.LibraryItem import com.nuvio.app.features.library.LibrarySection +import com.nuvio.app.features.watching.sync.WatchedSyncAdapter import kotlinx.coroutines.flow.Flow enum class TrackingLibraryTabKind { @@ -51,3 +52,8 @@ interface TrackingLibraryProvider { ) suspend fun toggleDefaultMembership(profileId: Int, item: LibraryItem) } + +/** Provider adapter for watched-history projection and explicit history mutations. */ +interface TrackingWatchedProvider : WatchedSyncAdapter { + val providerId: TrackingProviderId +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingSettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingSettings.kt index 211ec9c7..3402c782 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingSettings.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingSettings.kt @@ -24,6 +24,9 @@ object TrackingSettingsRepository { fun setLibrarySourceMode(source: LibrarySourceMode) = TraktSettingsRepository.setLibrarySourceMode(source) + fun setWatchProgressSource(source: WatchProgressSource, profileId: Int) = + TraktSettingsRepository.setWatchProgressSource(source, profileId) + fun setContinueWatchingDaysCap(days: Int) = TraktSettingsRepository.setContinueWatchingDaysCap(days) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt index 2bd43d27..d875ed87 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt @@ -7,14 +7,13 @@ import com.nuvio.app.core.tracking.ensureTrackingProvidersRegistered import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.profiles.ProfileRepository -import com.nuvio.app.features.simkl.SimklWatchedSyncAdapter import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.tracking.TrackingProviderRegistry +import com.nuvio.app.features.tracking.TrackingSettingsRepository import com.nuvio.app.features.tracking.WatchProgressSource import com.nuvio.app.features.tracking.effectiveWatchProgressSource -import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.tracking.providerId import com.nuvio.app.features.watching.sync.SupabaseWatchedSyncAdapter -import com.nuvio.app.features.watching.sync.TraktWatchedSyncAdapter import com.nuvio.app.features.watching.sync.WatchedDeltaEvent import com.nuvio.app.features.watching.sync.WatchedSyncAdapter import kotlinx.atomicfu.locks.SynchronizedObject @@ -43,15 +42,15 @@ private data class StoredWatchedPayload( val dirtyWatchedKeys: Set = emptySet(), ) -internal enum class WatchedTraktHistorySync { +internal enum class WatchedTrackerHistorySync { Mirror, Skip, } -internal fun shouldMirrorWatchedMarkToTraktHistory( - sync: WatchedTraktHistorySync, - isTraktAuthenticated: Boolean, -): Boolean = sync == WatchedTraktHistorySync.Mirror && isTraktAuthenticated +internal fun shouldMirrorWatchedMarkToTrackers( + sync: WatchedTrackerHistorySync, + hasConnectedTracker: Boolean, +): Boolean = sync == WatchedTrackerHistorySync.Mirror && hasConnectedTracker internal data class WatchedSourceOperation( val source: WatchProgressSource, @@ -67,29 +66,23 @@ internal fun isWatchedSourceOperationCurrent( internal fun watchedItemsForSource( source: WatchProgressSource, nuvioItems: Collection, - traktItems: Collection, - simklItems: Collection = emptyList(), -): Collection = when (source) { - WatchProgressSource.NUVIO_SYNC -> nuvioItems - WatchProgressSource.TRAKT -> traktItems - WatchProgressSource.SIMKL -> simklItems -} + providerItems: Map>, +): Collection = source.providerId + ?.let { providerId -> providerItems[providerId].orEmpty() } + ?: nuvioItems internal fun shouldPersistWatchedSource(source: WatchProgressSource): Boolean = - source == WatchProgressSource.NUVIO_SYNC + source.providerId == null internal fun replaceWatchedItemsForSource( source: WatchProgressSource, nuvioItems: MutableMap, - traktItems: MutableMap, - simklItems: MutableMap, + providerItems: MutableMap>, replacement: Map, ) { - val target = when (source) { - WatchProgressSource.NUVIO_SYNC -> nuvioItems - WatchProgressSource.TRAKT -> traktItems - WatchProgressSource.SIMKL -> simklItems - } + val target = source.providerId + ?.let { providerId -> providerItems.getOrPut(providerId, ::mutableMapOf) } + ?: nuvioItems target.clear() target.putAll(replacement) } @@ -126,34 +119,28 @@ object WatchedRepository { private var activeSource: WatchProgressSource = WatchProgressSource.NUVIO_SYNC private var sourceGeneration: Long = 0L private var nuvioItemsByKey: MutableMap = mutableMapOf() - private var traktItemsByKey: MutableMap = mutableMapOf() - private var simklItemsByKey: MutableMap = mutableMapOf() + private var providerItemsByKey: MutableMap> = mutableMapOf() private var nuvioFullyWatchedSeriesKeys: Set = emptySet() - private var traktFullyWatchedSeriesKeys: Set = emptySet() - private var simklFullyWatchedSeriesKeys: Set = emptySet() + private var providerFullyWatchedSeriesKeys: MutableMap> = mutableMapOf() private var nuvioHasLoaded: Boolean = false - private var traktHasLoaded: Boolean = false - private var simklHasLoaded: Boolean = false + private var loadedProviders: MutableSet = mutableSetOf() private var nuvioHasLoadedRemote: Boolean = false - private var traktHasLoadedRemote: Boolean = false - private var simklHasLoadedRemote: Boolean = false + private var providersLoadedFromRemote: MutableSet = mutableSetOf() private var nuvioDirtyWatchedKeys: MutableSet = mutableSetOf() private var lastSuccessfulPushEpochMs: Long = 0L private var deltaCursorEventId: Long = 0L private var deltaInitialized: Boolean = false internal var syncAdapter: WatchedSyncAdapter = SupabaseWatchedSyncAdapter - internal var traktSyncAdapter: WatchedSyncAdapter = TraktWatchedSyncAdapter - internal var simklSyncAdapter: WatchedSyncAdapter = SimklWatchedSyncAdapter fun ensureLoaded() { ensureTrackingProvidersRegistered() TrackingProviderRegistry.ensureLoaded() - TraktSettingsRepository.ensureLoaded() + TrackingSettingsRepository.ensureLoaded() if (!hasLoaded) { loadFromDisk(ProfileRepository.activeProfileId) activateEffectiveSource( effectiveWatchedSource( - requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource, + requestedSource = TrackingSettingsRepository.uiState.value.watchProgressSource, connectedProviderIds = TrackingProviderRegistry.connectedProviderIdsSnapshot(), ), ) @@ -179,17 +166,13 @@ object WatchedRepository { activeSource = WatchProgressSource.NUVIO_SYNC sourceGeneration += 1L nuvioItemsByKey.clear() - traktItemsByKey.clear() - simklItemsByKey.clear() + providerItemsByKey.clear() nuvioFullyWatchedSeriesKeys = emptySet() - traktFullyWatchedSeriesKeys = emptySet() - simklFullyWatchedSeriesKeys = emptySet() + providerFullyWatchedSeriesKeys.clear() nuvioHasLoaded = false - traktHasLoaded = false - simklHasLoaded = false + loadedProviders.clear() nuvioHasLoadedRemote = false - traktHasLoadedRemote = false - simklHasLoadedRemote = false + providersLoadedFromRemote.clear() nuvioDirtyWatchedKeys.clear() lastSuccessfulPushEpochMs = 0L deltaCursorEventId = 0L @@ -205,17 +188,13 @@ object WatchedRepository { sourceGeneration += 1L hasLoaded = true nuvioItemsByKey.clear() - traktItemsByKey.clear() - simklItemsByKey.clear() + providerItemsByKey.clear() nuvioFullyWatchedSeriesKeys = emptySet() - traktFullyWatchedSeriesKeys = emptySet() - simklFullyWatchedSeriesKeys = emptySet() + providerFullyWatchedSeriesKeys.clear() nuvioHasLoaded = true - traktHasLoaded = false - simklHasLoaded = false + loadedProviders.clear() nuvioHasLoadedRemote = false - traktHasLoadedRemote = false - simklHasLoadedRemote = false + providersLoadedFromRemote.clear() nuvioDirtyWatchedKeys.clear() val payload = WatchedStorage.loadPayload(profileId).orEmpty().trim() @@ -253,20 +232,13 @@ object WatchedRepository { private fun activateEffectiveSource(source: WatchProgressSource): WatchProgressSource { if (activeSource == source) return source - when (source) { - WatchProgressSource.TRAKT -> { - traktItemsByKey.clear() - traktFullyWatchedSeriesKeys = emptySet() - traktHasLoaded = false - traktHasLoadedRemote = false - } - WatchProgressSource.SIMKL -> { - simklItemsByKey.clear() - simklFullyWatchedSeriesKeys = emptySet() - simklHasLoaded = false - simklHasLoadedRemote = false - } - WatchProgressSource.NUVIO_SYNC -> nuvioHasLoadedRemote = false + source.providerId?.let { providerId -> + providerItemsByKey.getOrPut(providerId, ::mutableMapOf).clear() + providerFullyWatchedSeriesKeys[providerId] = emptySet() + loadedProviders -= providerId + providersLoadedFromRemote -= providerId + } ?: run { + nuvioHasLoadedRemote = false } activeSource = source sourceGeneration += 1L @@ -299,11 +271,11 @@ object WatchedRepository { suspend fun pullFromServer(profileId: Int) { TrackingProviderRegistry.ensureLoaded() - TraktSettingsRepository.ensureLoaded() + TrackingSettingsRepository.ensureLoaded() refreshForSource( profileId = profileId, source = effectiveWatchedSource( - requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource, + requestedSource = TrackingSettingsRepository.uiState.value.watchProgressSource, connectedProviderIds = TrackingProviderRegistry.connectedProviderIdsSnapshot(), ), forceSnapshot = false, @@ -312,11 +284,11 @@ object WatchedRepository { suspend fun forceSnapshotRefreshFromServer(profileId: Int) { TrackingProviderRegistry.ensureLoaded() - TraktSettingsRepository.ensureLoaded() + TrackingSettingsRepository.ensureLoaded() refreshForSource( profileId = profileId, source = effectiveWatchedSource( - requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource, + requestedSource = TrackingSettingsRepository.uiState.value.watchProgressSource, connectedProviderIds = TrackingProviderRegistry.connectedProviderIdsSnapshot(), ), forceSnapshot = true, @@ -329,7 +301,7 @@ object WatchedRepository { forceSnapshot: Boolean = true, ): Boolean { TrackingProviderRegistry.ensureLoaded() - TraktSettingsRepository.ensureLoaded() + TrackingSettingsRepository.ensureLoaded() if (ProfileRepository.activeProfileId != profileId) { log.d { "Skipping watched refresh for inactive profile $profileId" } return false @@ -340,7 +312,7 @@ object WatchedRepository { val effectiveSource = activateEffectiveSource(source) val operation = newRefreshOperation(profileId) ?: return false - if (effectiveSource == WatchProgressSource.NUVIO_SYNC) { + if (effectiveSource.providerId == null) { val authState = AuthRepository.state.value if (authState !is AuthState.Authenticated || authState.isAnonymous) { // Local watched state is authoritative when this account has no Nuvio upstream. @@ -351,30 +323,25 @@ object WatchedRepository { } } return try { - when (effectiveSource) { - WatchProgressSource.TRAKT -> pullSnapshotFromAdapter( - adapter = traktSyncAdapter, + effectiveSource.providerId?.let { providerId -> + val provider = TrackingProviderRegistry.watchedProvider(providerId) + ?: return false + pullSnapshotFromAdapter( + adapter = provider, operation = operation, profileId = profileId, resetDeltaState = true, ) - WatchProgressSource.SIMKL -> pullSnapshotFromAdapter( - adapter = simklSyncAdapter, + } ?: if (forceSnapshot) { + refreshNuvioSnapshot( + operation = operation, + profileId = profileId, + ) + } else { + pullSupabaseDeltaFromServer( operation = operation, profileId = profileId, - resetDeltaState = true, ) - WatchProgressSource.NUVIO_SYNC -> if (forceSnapshot) { - refreshNuvioSnapshot( - operation = operation, - profileId = profileId, - ) - } else { - pullSupabaseDeltaFromServer( - operation = operation, - profileId = profileId, - ) - } } } catch (error: CancellationException) { throw error @@ -438,30 +405,22 @@ object WatchedRepository { replaceWatchedItemsForSource( source = operation.sourceOperation.source, nuvioItems = nuvioItemsByKey, - traktItems = traktItemsByKey, - simklItems = simklItemsByKey, + providerItems = providerItemsByKey, replacement = mergedSnapshot.items, ) fullyWatchedSeriesKeys?.let { keys -> setFullyWatchedSeriesKeysForSource(operation.sourceOperation.source, keys) } - when (operation.sourceOperation.source) { - WatchProgressSource.NUVIO_SYNC -> { - nuvioDirtyWatchedKeys = mergedSnapshot.dirtyKeys.toMutableSet() - nuvioHasLoaded = true - nuvioHasLoadedRemote = true - if (resetDeltaState) { - deltaCursorEventId = 0L - deltaInitialized = false - } - } - WatchProgressSource.TRAKT -> { - traktHasLoaded = true - traktHasLoadedRemote = true - } - WatchProgressSource.SIMKL -> { - simklHasLoaded = true - simklHasLoadedRemote = true + operation.sourceOperation.source.providerId?.let { providerId -> + loadedProviders += providerId + providersLoadedFromRemote += providerId + } ?: run { + nuvioDirtyWatchedKeys = mergedSnapshot.dirtyKeys.toMutableSet() + nuvioHasLoaded = true + nuvioHasLoadedRemote = true + if (resetDeltaState) { + deltaCursorEventId = 0L + deltaInitialized = false } } publish() @@ -645,36 +604,28 @@ object WatchedRepository { } private fun itemsForSource(source: WatchProgressSource): MutableMap = - when (source) { - WatchProgressSource.NUVIO_SYNC -> nuvioItemsByKey - WatchProgressSource.TRAKT -> traktItemsByKey - WatchProgressSource.SIMKL -> simklItemsByKey - } + source.providerId + ?.let { providerId -> providerItemsByKey.getOrPut(providerId, ::mutableMapOf) } + ?: nuvioItemsByKey private fun fullyWatchedSeriesKeysForSource(source: WatchProgressSource): Set = - when (source) { - WatchProgressSource.NUVIO_SYNC -> nuvioFullyWatchedSeriesKeys - WatchProgressSource.TRAKT -> traktFullyWatchedSeriesKeys - WatchProgressSource.SIMKL -> simklFullyWatchedSeriesKeys - } + source.providerId + ?.let { providerId -> providerFullyWatchedSeriesKeys[providerId].orEmpty() } + ?: nuvioFullyWatchedSeriesKeys private fun setFullyWatchedSeriesKeysForSource( source: WatchProgressSource, keys: Set, ) { - when (source) { - WatchProgressSource.NUVIO_SYNC -> nuvioFullyWatchedSeriesKeys = keys - WatchProgressSource.TRAKT -> traktFullyWatchedSeriesKeys = keys - WatchProgressSource.SIMKL -> simklFullyWatchedSeriesKeys = keys + source.providerId?.let { providerId -> + providerFullyWatchedSeriesKeys[providerId] = keys + } ?: run { + nuvioFullyWatchedSeriesKeys = keys } } private fun hasLoadedSource(source: WatchProgressSource): Boolean = - when (source) { - WatchProgressSource.NUVIO_SYNC -> nuvioHasLoaded - WatchProgressSource.TRAKT -> traktHasLoaded - WatchProgressSource.SIMKL -> simklHasLoaded - } + source.providerId?.let(loadedProviders::contains) ?: nuvioHasLoaded fun toggleWatched(item: WatchedItem) { ensureLoaded() @@ -693,16 +644,20 @@ object WatchedRepository { } fun markWatched(items: Collection) { - markWatched(items = items, traktHistorySync = WatchedTraktHistorySync.Mirror) + markWatched(items = items, trackerHistorySync = WatchedTrackerHistorySync.Mirror) } internal fun markWatchedFromPlaybackCompletion(item: WatchedItem, syncRemote: Boolean = true) { - markWatched(items = listOf(item), traktHistorySync = WatchedTraktHistorySync.Skip, syncRemote = syncRemote) + markWatched( + items = listOf(item), + trackerHistorySync = WatchedTrackerHistorySync.Skip, + syncRemote = syncRemote, + ) } private fun markWatched( items: Collection, - traktHistorySync: WatchedTraktHistorySync, + trackerHistorySync: WatchedTrackerHistorySync, syncRemote: Boolean = true, ) { ensureLoaded() @@ -716,7 +671,7 @@ object WatchedRepository { timestampedItems.forEach { watchedItem -> val key = watchedItemKey(watchedItem.type, watchedItem.id, watchedItem.season, watchedItem.episode) targetItems[key] = watchedItem - if (source == WatchProgressSource.NUVIO_SYNC) { + if (source.providerId == null) { nuvioDirtyWatchedKeys += key } } @@ -727,7 +682,7 @@ object WatchedRepository { if (syncRemote) { pushMarksToServer( items = timestampedItems, - traktHistorySync = traktHistorySync, + trackerHistorySync = trackerHistorySync, source = source, ) } @@ -765,7 +720,7 @@ object WatchedRepository { val removedItems = items.mapNotNull { watchedItem -> val key = watchedItemKey(watchedItem.type, watchedItem.id, watchedItem.season, watchedItem.episode) targetItems.remove(key)?.also { - if (source == WatchProgressSource.NUVIO_SYNC) { + if (source.providerId == null) { nuvioDirtyWatchedKeys -= key } } @@ -869,7 +824,7 @@ object WatchedRepository { private fun pushMarksToServer( items: Collection, - traktHistorySync: WatchedTraktHistorySync, + trackerHistorySync: WatchedTrackerHistorySync, source: WatchProgressSource, ) { val profileId = currentProfileId @@ -880,7 +835,7 @@ object WatchedRepository { val pushed = pushToTargetsForSource( profileId = profileId, items = items, - traktHistorySync = traktHistorySync, + trackerHistorySync = trackerHistorySync, source = source, ) if (pushed && shouldPersistWatchedSource(source)) { @@ -919,8 +874,7 @@ object WatchedRepository { val items = watchedItemsForSource( source = activeSource, nuvioItems = nuvioItemsByKey.values, - traktItems = traktItemsByKey.values, - simklItems = simklItemsByKey.values, + providerItems = providerItemsByKey.mapValues { (_, itemsByKey) -> itemsByKey.values }, ) .map(WatchedItem::normalizedMarkedAt) .sortedByDescending { it.markedAtEpochMs } @@ -931,11 +885,9 @@ object WatchedRepository { watchedItemKey(it.type, it.id, it.season, it.episode) }, isLoaded = hasLoadedSource(activeSource), - hasLoadedRemoteItems = when (activeSource) { - WatchProgressSource.NUVIO_SYNC -> nuvioHasLoadedRemote - WatchProgressSource.TRAKT -> traktHasLoadedRemote - WatchProgressSource.SIMKL -> simklHasLoadedRemote - }, + hasLoadedRemoteItems = activeSource.providerId + ?.let(providersLoadedFromRemote::contains) + ?: nuvioHasLoadedRemote, ) } @@ -988,11 +940,11 @@ object WatchedRepository { private suspend fun pushToTargetsForSource( profileId: Int, items: Collection, - traktHistorySync: WatchedTraktHistorySync, + trackerHistorySync: WatchedTrackerHistorySync, source: WatchProgressSource, ): Boolean { var anySucceeded = false - if (source == WatchProgressSource.NUVIO_SYNC) { + if (source.providerId == null) { try { syncAdapter.push(profileId = profileId, items = items) anySucceeded = true @@ -1003,15 +955,15 @@ object WatchedRepository { } } - if (traktHistorySync == WatchedTraktHistorySync.Mirror) { - connectedTrackerSyncAdapters().forEach { (providerId, adapter) -> + if (trackerHistorySync == WatchedTrackerHistorySync.Mirror) { + TrackingProviderRegistry.connectedWatchedProviders().forEach { provider -> try { - adapter.push(profileId = profileId, items = items) + provider.push(profileId = profileId, items = items) anySucceeded = true } catch (error: CancellationException) { throw error } catch (error: Throwable) { - log.e(error) { "Failed to push watched items to ${providerId.storageId}" } + log.e(error) { "Failed to push watched items to ${provider.providerId.storageId}" } } } } @@ -1023,7 +975,7 @@ object WatchedRepository { items: Collection, source: WatchProgressSource, ) { - if (source == WatchProgressSource.NUVIO_SYNC) { + if (source.providerId == null) { try { syncAdapter.delete(profileId = profileId, items = items) } catch (error: CancellationException) { @@ -1033,23 +985,17 @@ object WatchedRepository { } } - connectedTrackerSyncAdapters().forEach { (providerId, adapter) -> + TrackingProviderRegistry.connectedWatchedProviders().forEach { provider -> try { - adapter.delete(profileId = profileId, items = items) + provider.delete(profileId = profileId, items = items) } catch (error: CancellationException) { throw error } catch (error: Throwable) { - log.e(error) { "Failed to delete watched items from ${providerId.storageId}" } + log.e(error) { "Failed to delete watched items from ${provider.providerId.storageId}" } } } } - private fun connectedTrackerSyncAdapters(): List> = - listOf( - TrackingProviderId.TRAKT to traktSyncAdapter, - TrackingProviderId.SIMKL to simklSyncAdapter, - ).filter { (providerId, _) -> TrackingProviderRegistry.isAuthenticated(providerId) } - private fun accountScopeSnapshot(): CoroutineScope = synchronized(accountScopeLock) { accountScope @@ -1115,19 +1061,6 @@ internal fun acknowledgeSuccessfulWatchedPush( return remainingDirtyKeys } -internal fun shouldUseTraktWatchedSync( - isAuthenticated: Boolean, - source: WatchProgressSource, -): Boolean = isAuthenticated && source == WatchProgressSource.TRAKT - -internal fun effectiveWatchedSource( - requestedSource: WatchProgressSource, - isTraktAuthenticated: Boolean, -): WatchProgressSource = effectiveWatchedSource( - requestedSource = requestedSource, - connectedProviderIds = if (isTraktAuthenticated) setOf(TrackingProviderId.TRAKT) else emptySet(), -) - internal fun effectiveWatchedSource( requestedSource: WatchProgressSource, connectedProviderIds: Set, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt index b2490f75..755e14bf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt @@ -4,6 +4,8 @@ import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.RawHttpResponse import com.nuvio.app.features.addons.httpRequestRaw import com.nuvio.app.features.tmdb.TmdbService +import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.TrackingWatchedProvider import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktEpisodeMappingService import com.nuvio.app.features.trakt.TraktPlatformClock @@ -23,7 +25,8 @@ private const val WATCHED_MAX_PAGES = 1_000 private const val WATCHED_SHOWS_EXTENDED = "progress" -object TraktWatchedSyncAdapter : WatchedSyncAdapter { +object TraktWatchedSyncAdapter : TrackingWatchedProvider { + override val providerId: TrackingProviderId = TrackingProviderId.TRAKT private val log = Logger.withTag("TraktWatchedSync") private val json = Json { ignoreUnknownKeys = true diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt index 8a83ff41..512e8f67 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt @@ -8,9 +8,9 @@ import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.tracking.DEFAULT_WATCH_PROGRESS_SOURCE import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.tracking.TrackingProviderRegistry +import com.nuvio.app.features.tracking.TrackingSettingsRepository import com.nuvio.app.features.tracking.WatchProgressSource import com.nuvio.app.features.tracking.effectiveWatchProgressSource -import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.watched.WatchedRepository import kotlinx.atomicfu.atomic import kotlinx.atomicfu.locks.SynchronizedObject @@ -235,7 +235,7 @@ object WatchProgressSourceCoordinator { if (observeJob?.isActive == true) return observeJob = scope.launch { combine( - TraktSettingsRepository.uiState, + TrackingSettingsRepository.uiState, TrackingProviderRegistry.connectedProviderIds, AuthRepository.state, ProfileRepository.state, @@ -264,7 +264,7 @@ object WatchProgressSourceCoordinator { private fun ensureSourceStateLoaded() { ensureTrackingProvidersRegistered() TrackingProviderRegistry.ensureLoaded() - TraktSettingsRepository.ensureLoaded() + TrackingSettingsRepository.ensureLoaded() } suspend fun selectSource( @@ -275,7 +275,7 @@ object WatchProgressSourceCoordinator { ensureSourceStateLoadedForGeneration(operationGeneration) synchronized(startLock) { ensureCoordinatorGeneration(operationGeneration) - TraktSettingsRepository.setWatchProgressSource(source, profileId) + TrackingSettingsRepository.setWatchProgressSource(source, profileId) } val context = currentContext(profileId) return try { @@ -481,7 +481,7 @@ object WatchProgressSourceCoordinator { private fun currentContext(profileId: Int): WatchProgressSourceContext = buildContext( profileId = profileId, - requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource, + requestedSource = TrackingSettingsRepository.uiState.value.watchProgressSource, connectedProviderIds = TrackingProviderRegistry.connectedProviderIdsSnapshot(), authState = AuthRepository.state.value, ) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedModelsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedModelsTest.kt index 974bf6c9..657fc8b2 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedModelsTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedModelsTest.kt @@ -1,7 +1,9 @@ package com.nuvio.app.features.watched import com.nuvio.app.features.trakt.TraktPlatformClock +import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.tracking.WatchProgressSource +import com.nuvio.app.features.tracking.providerId import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -21,24 +23,9 @@ class WatchedModelsTest { } @Test - fun `Trakt watched sync follows selected watch progress source`() { - assertTrue( - shouldUseTraktWatchedSync( - isAuthenticated = true, - source = WatchProgressSource.TRAKT, - ), - ) - assertFalse( - shouldUseTraktWatchedSync( - isAuthenticated = true, - source = WatchProgressSource.NUVIO_SYNC, - ), - ) - assertFalse( - shouldUseTraktWatchedSync( - isAuthenticated = false, - source = WatchProgressSource.TRAKT, - ), - ) + fun `remote watched sources carry provider identity`() { + assertEquals(TrackingProviderId.TRAKT, WatchProgressSource.TRAKT.providerId) + assertEquals(TrackingProviderId.SIMKL, WatchProgressSource.SIMKL.providerId) + assertEquals(null, WatchProgressSource.NUVIO_SYNC.providerId) } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt index cea8d6ff..e7de03c5 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.watched import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaVideo +import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.tracking.WatchProgressSource import kotlin.test.Test import kotlin.test.assertEquals @@ -136,23 +137,23 @@ class WatchedRepositoryTest { } @Test - fun playbackCompletionWatchedMarks_doNotMirrorToTraktHistory() { + fun playbackCompletionWatchedMarks_doNotMirrorToTrackerHistory() { assertFalse( - shouldMirrorWatchedMarkToTraktHistory( - sync = WatchedTraktHistorySync.Skip, - isTraktAuthenticated = true, + shouldMirrorWatchedMarkToTrackers( + sync = WatchedTrackerHistorySync.Skip, + hasConnectedTracker = true, ), ) assertTrue( - shouldMirrorWatchedMarkToTraktHistory( - sync = WatchedTraktHistorySync.Mirror, - isTraktAuthenticated = true, + shouldMirrorWatchedMarkToTrackers( + sync = WatchedTrackerHistorySync.Mirror, + hasConnectedTracker = true, ), ) assertFalse( - shouldMirrorWatchedMarkToTraktHistory( - sync = WatchedTraktHistorySync.Mirror, - isTraktAuthenticated = false, + shouldMirrorWatchedMarkToTrackers( + sync = WatchedTrackerHistorySync.Mirror, + hasConnectedTracker = false, ), ) } @@ -168,8 +169,10 @@ class WatchedRepositoryTest { watchedItemsForSource( source = WatchProgressSource.NUVIO_SYNC, nuvioItems = listOf(nuvioItem), - traktItems = listOf(traktItem), - simklItems = listOf(simklItem), + providerItems = mapOf( + TrackingProviderId.TRAKT to listOf(traktItem), + TrackingProviderId.SIMKL to listOf(simklItem), + ), ), ) assertEquals( @@ -177,8 +180,10 @@ class WatchedRepositoryTest { watchedItemsForSource( source = WatchProgressSource.TRAKT, nuvioItems = listOf(nuvioItem), - traktItems = listOf(traktItem), - simklItems = listOf(simklItem), + providerItems = mapOf( + TrackingProviderId.TRAKT to listOf(traktItem), + TrackingProviderId.SIMKL to listOf(simklItem), + ), ), ) assertEquals( @@ -186,8 +191,10 @@ class WatchedRepositoryTest { watchedItemsForSource( source = WatchProgressSource.SIMKL, nuvioItems = listOf(nuvioItem), - traktItems = listOf(traktItem), - simklItems = listOf(simklItem), + providerItems = mapOf( + TrackingProviderId.TRAKT to listOf(traktItem), + TrackingProviderId.SIMKL to listOf(simklItem), + ), ), ) } @@ -205,19 +212,22 @@ class WatchedRepositoryTest { val previousTraktItem = watchedItem(id = "old-trakt", markedAtEpochMs = 2_000L) val refreshedTraktItem = watchedItem(id = "new-trakt", markedAtEpochMs = 3_000L) val nuvioItems = mutableMapOf("nuvio" to nuvioItem) - val traktItems = mutableMapOf("old-trakt" to previousTraktItem) - val simklItems = mutableMapOf() + val providerItems = mutableMapOf( + TrackingProviderId.TRAKT to mutableMapOf("old-trakt" to previousTraktItem), + ) replaceWatchedItemsForSource( source = WatchProgressSource.TRAKT, nuvioItems = nuvioItems, - traktItems = traktItems, - simklItems = simklItems, + providerItems = providerItems, replacement = mapOf("new-trakt" to refreshedTraktItem), ) assertEquals(mapOf("nuvio" to nuvioItem), nuvioItems) - assertEquals(mapOf("new-trakt" to refreshedTraktItem), traktItems) + assertEquals( + mapOf("new-trakt" to refreshedTraktItem), + providerItems[TrackingProviderId.TRAKT].orEmpty(), + ) } @Test @@ -226,14 +236,14 @@ class WatchedRepositoryTest { WatchProgressSource.NUVIO_SYNC, effectiveWatchedSource( requestedSource = WatchProgressSource.TRAKT, - isTraktAuthenticated = false, + connectedProviderIds = emptySet(), ), ) assertEquals( WatchProgressSource.TRAKT, effectiveWatchedSource( requestedSource = WatchProgressSource.TRAKT, - isTraktAuthenticated = true, + connectedProviderIds = setOf(TrackingProviderId.TRAKT), ), ) } @@ -244,7 +254,7 @@ class WatchedRepositoryTest { WatchProgressSource.SIMKL, effectiveWatchedSource( requestedSource = WatchProgressSource.SIMKL, - connectedProviderIds = setOf(com.nuvio.app.features.tracking.TrackingProviderId.SIMKL), + connectedProviderIds = setOf(TrackingProviderId.SIMKL), ), ) assertEquals( From eb5bf78c37ad886b2597aa2f4970c95678dd8f8e Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:38:21 +0530 Subject: [PATCH 12/59] refactor(tracking): register progress read providers --- .../tracking/TrackingProviderBootstrap.kt | 4 + .../app/features/library/LibraryRepository.kt | 5 +- .../simkl/SimklApplicationAdapters.kt | 28 ++ .../app/features/tracking/TrackingProvider.kt | 18 + .../app/features/tracking/TrackingReads.kt | 35 ++ .../trakt/TraktTrackingProgressProvider.kt | 90 +++++ .../app/features/watched/WatchedRepository.kt | 10 +- .../watchprogress/WatchProgressRepository.kt | 330 ++++++------------ .../watchprogress/WatchProgressRules.kt | 4 +- .../WatchProgressSourceCoordinator.kt | 6 +- .../watchprogress/WatchProgressRulesTest.kt | 8 +- 11 files changed, 298 insertions(+), 240 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingProgressProvider.kt 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 a1226d1c..bf676221 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 @@ -5,12 +5,14 @@ import com.nuvio.app.features.simkl.SimklMutationRepository import com.nuvio.app.features.simkl.SimklLibraryRepository import com.nuvio.app.features.simkl.SimklProgressRepository import com.nuvio.app.features.simkl.SimklTrackingLibraryProvider +import com.nuvio.app.features.simkl.SimklTrackingProgressProvider import com.nuvio.app.features.simkl.SimklWatchedSyncAdapter import com.nuvio.app.features.simkl.SimklSyncRepository import com.nuvio.app.features.tracking.TrackingProviderRegistry import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktScrobbleRepository import com.nuvio.app.features.trakt.TraktTrackingLibraryProvider +import com.nuvio.app.features.trakt.TraktTrackingProgressProvider import com.nuvio.app.features.watching.sync.TraktWatchedSyncAdapter fun ensureTrackingProvidersRegistered() { @@ -25,4 +27,6 @@ fun ensureTrackingProvidersRegistered() { TrackingProviderRegistry.registerLibraryProvider(SimklTrackingLibraryProvider) TrackingProviderRegistry.registerWatchedProvider(TraktWatchedSyncAdapter) TrackingProviderRegistry.registerWatchedProvider(SimklWatchedSyncAdapter) + TrackingProviderRegistry.registerProgressProvider(TraktTrackingProgressProvider) + TrackingProviderRegistry.registerProgressProvider(SimklTrackingProgressProvider) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt index 441c21f3..88e95a2e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt @@ -594,7 +594,10 @@ object LibraryRepository { private fun effectiveLibrarySourceMode(): LibrarySourceMode = resolveEffectiveLibrarySourceMode( requestedSource = selectedLibrarySourceMode(), - isProviderAuthenticated = TrackingProviderRegistry::isAuthenticated, + isProviderAuthenticated = { providerId -> + TrackingProviderRegistry.libraryProvider(providerId) != null && + TrackingProviderRegistry.isAuthenticated(providerId) + }, ) private fun activeLibraryProvider( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt index 6f5721f2..1a4ab121 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt @@ -11,6 +11,8 @@ import com.nuvio.app.features.tracking.TrackingLibraryTab import com.nuvio.app.features.tracking.TrackingLibraryTabKind import com.nuvio.app.features.tracking.TrackingListStatus import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.TrackingProgressProvider +import com.nuvio.app.features.tracking.TrackingProgressSnapshot import com.nuvio.app.features.tracking.TrackingWatchedProvider import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watchprogress.WatchProgressEntry @@ -110,6 +112,8 @@ object SimklTrackingLibraryProvider : TrackingLibraryProvider { override fun ensureLoaded() = SimklLibraryRepository.ensureLoaded() + override fun onProfileChanged() = SimklLibraryRepository.ensureLoaded() + override suspend fun refresh() = SimklLibraryRepository.refreshNow() override fun snapshot(): TrackingLibrarySnapshot { @@ -299,6 +303,30 @@ object SimklProgressRepository { } } +object SimklTrackingProgressProvider : TrackingProgressProvider { + override val providerId: TrackingProviderId = TrackingProviderId.SIMKL + override val changes: Flow = SimklProgressRepository.uiState.map { Unit } + + override fun ensureLoaded() = SimklProgressRepository.ensureLoaded() + + override fun onProfileChanged() = SimklProgressRepository.ensureLoaded() + + override suspend fun refresh(force: Boolean, sourceChanged: Boolean) = + SimklProgressRepository.refreshNow() + + override fun snapshot(): TrackingProgressSnapshot { + val state = SimklProgressRepository.uiState.value + return TrackingProgressSnapshot( + entries = state.entries, + hasLoadedRemoteProgress = state.hasLoadedRemoteProgress, + errorMessage = state.errorMessage, + ) + } + + override suspend fun removeProgress(entries: Collection) = + SimklProgressRepository.removeProgress(entries) +} + private fun SimklSyncSnapshot.canSafelyRemoveFromSimklWatchlist(contentId: String): Boolean = entries.firstOrNull { entry -> entry.media?.canonicalContentId().equals(contentId, ignoreCase = true) } ?.let { entry -> 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 4ae2d43a..56dad2c4 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 @@ -76,6 +76,7 @@ object TrackingProviderRegistry { private val scrobblers = mutableMapOf() private val libraryProviders = mutableMapOf() private val watchedProviders = mutableMapOf() + private val progressProviders = mutableMapOf() private val _connectedProviderIds = MutableStateFlow>(emptySet()) val connectedProviderIds: StateFlow> = _connectedProviderIds.asStateFlow() @@ -123,6 +124,10 @@ object TrackingProviderRegistry { watchedProviders[provider.providerId] = provider } + fun registerProgressProvider(provider: TrackingProgressProvider) = synchronized(lock) { + progressProviders[provider.providerId] = provider + } + fun authProvider(id: TrackingProviderId): TrackingAuthProvider? = synchronized(lock) { authProviders[id] } @@ -168,6 +173,16 @@ object TrackingProviderRegistry { watchedProviders[id] } + fun progressProvider(id: TrackingProviderId): TrackingProgressProvider? = synchronized(lock) { + progressProviders[id] + } + + fun progressProviders(): List = synchronized(lock) { + progressProviders.entries + .sortedBy { (id, _) -> id.ordinal } + .map { (_, provider) -> provider } + } + fun connectedListWriters(): List = connectedPorts(listWriters, TrackingCapability.LIBRARY_WRITE) @@ -183,6 +198,9 @@ object TrackingProviderRegistry { fun connectedWatchedProviders(): List = connectedPorts(watchedProviders, TrackingCapability.WATCHED_READ) + fun connectedProgressProviders(): List = + connectedPorts(progressProviders, TrackingCapability.PROGRESS_READ) + fun handleAuthCallback(url: String): Boolean = providersWith(TrackingCapability.AUTHENTICATION) .any { provider -> provider.handleAuthCallback(url) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt index 739ae3de..6f59e155 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt @@ -3,6 +3,7 @@ package com.nuvio.app.features.tracking import com.nuvio.app.features.library.LibraryItem import com.nuvio.app.features.library.LibrarySection import com.nuvio.app.features.watching.sync.WatchedSyncAdapter +import com.nuvio.app.features.watchprogress.WatchProgressEntry import kotlinx.coroutines.flow.Flow enum class TrackingLibraryTabKind { @@ -57,3 +58,37 @@ interface TrackingLibraryProvider { interface TrackingWatchedProvider : WatchedSyncAdapter { val providerId: TrackingProviderId } + +data class TrackingProgressSnapshot( + val entries: List = emptyList(), + val hasLoadedRemoteProgress: Boolean = false, + val errorMessage: String? = null, +) + +/** Provider-owned projection and policy for remote playback progress. */ +interface TrackingProgressProvider { + val providerId: TrackingProviderId + val changes: Flow + + /** True when the provider projection already contains display-ready metadata. */ + val providesCompleteMetadata: Boolean + get() = false + + /** True when completed progress is already reconciled into watched history by the provider. */ + val ownsCompletedHistoryProjection: Boolean + get() = false + + fun ensureLoaded() + fun onProfileChanged() = Unit + fun clearLocalState() = Unit + fun onActivated() = Unit + suspend fun refresh(force: Boolean, sourceChanged: Boolean) + fun snapshot(): TrackingProgressSnapshot + suspend fun removeProgress(entries: Collection) + fun applyOptimisticRemoval(entries: Collection) = Unit + fun applyOptimisticProgress(entry: WatchProgressEntry) = Unit + fun normalizeParentContentId(parentContentId: String, videoId: String?): String = parentContentId + fun shouldRetainLocalEntry(entry: WatchProgressEntry): Boolean = true + suspend fun refreshEpisodeProgress(contentId: String, forceRefresh: Boolean) = Unit + fun isHiddenFromProgress(contentId: String): Boolean = false +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingProgressProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingProgressProvider.kt new file mode 100644 index 00000000..e1f98ea9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingProgressProvider.kt @@ -0,0 +1,90 @@ +package com.nuvio.app.features.trakt + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.tracking.TrackingProgressProvider +import com.nuvio.app.features.tracking.TrackingProgressSnapshot +import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.watchprogress.WatchProgressEntry +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +object TraktTrackingProgressProvider : TrackingProgressProvider { + private val log = Logger.withTag("TraktProgressPort") + + override val providerId: TrackingProviderId = TrackingProviderId.TRAKT + override val changes: Flow = TraktProgressRepository.uiState.map { Unit } + override val providesCompleteMetadata: Boolean = true + override val ownsCompletedHistoryProjection: Boolean = true + + override fun ensureLoaded() = TraktProgressRepository.ensureLoaded() + + override fun onProfileChanged() = TraktProgressRepository.onProfileChanged() + + override fun clearLocalState() = TraktProgressRepository.clearLocalState() + + override fun onActivated() = TraktProgressRepository.clearLocalState() + + override suspend fun refresh(force: Boolean, sourceChanged: Boolean) { + if (force || sourceChanged) { + TraktProgressRepository.invalidateAndRefresh() + } else { + TraktProgressRepository.refreshNow() + } + } + + override fun snapshot(): TrackingProgressSnapshot { + val state = TraktProgressRepository.uiState.value + return TrackingProgressSnapshot( + entries = state.entries, + hasLoadedRemoteProgress = state.hasLoadedRemoteProgress, + errorMessage = state.errorMessage, + ) + } + + override suspend fun removeProgress(entries: Collection) { + entries + .filter { entry -> isTraktCompatibleId(entry.parentMetaId) } + .distinctBy { entry -> Triple(entry.parentMetaId, entry.seasonNumber, entry.episodeNumber) } + .forEach { entry -> + try { + TraktProgressRepository.removeProgress( + contentId = entry.parentMetaId, + seasonNumber = entry.seasonNumber, + episodeNumber = entry.episodeNumber, + ) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.e(error) { "Failed to remove Trakt progress for ${entry.parentMetaId}" } + } + } + } + + override fun applyOptimisticRemoval(entries: Collection) { + entries + .distinctBy { entry -> Triple(entry.parentMetaId, entry.seasonNumber, entry.episodeNumber) } + .forEach { entry -> + TraktProgressRepository.applyOptimisticRemoval( + contentId = entry.parentMetaId, + seasonNumber = entry.seasonNumber, + episodeNumber = entry.episodeNumber, + ) + } + } + + override fun applyOptimisticProgress(entry: WatchProgressEntry) = + TraktProgressRepository.applyOptimisticProgress(entry) + + override fun normalizeParentContentId(parentContentId: String, videoId: String?): String = + resolveEffectiveContentId(parentContentId, videoId) + + override fun shouldRetainLocalEntry(entry: WatchProgressEntry): Boolean = + !isTraktCompatibleId(entry.parentMetaId) + + override suspend fun refreshEpisodeProgress(contentId: String, forceRefresh: Boolean) = + TraktProgressRepository.refreshEpisodeProgress(contentId, forceRefresh) + + override fun isHiddenFromProgress(contentId: String): Boolean = + TraktProgressRepository.isShowHiddenFromProgress(contentId) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt index d875ed87..67dfdd47 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt @@ -141,7 +141,7 @@ object WatchedRepository { activateEffectiveSource( effectiveWatchedSource( requestedSource = TrackingSettingsRepository.uiState.value.watchProgressSource, - connectedProviderIds = TrackingProviderRegistry.connectedProviderIdsSnapshot(), + connectedProviderIds = connectedWatchedProviderIds(), ), ) } @@ -276,7 +276,7 @@ object WatchedRepository { profileId = profileId, source = effectiveWatchedSource( requestedSource = TrackingSettingsRepository.uiState.value.watchProgressSource, - connectedProviderIds = TrackingProviderRegistry.connectedProviderIdsSnapshot(), + connectedProviderIds = connectedWatchedProviderIds(), ), forceSnapshot = false, ) @@ -289,7 +289,7 @@ object WatchedRepository { profileId = profileId, source = effectiveWatchedSource( requestedSource = TrackingSettingsRepository.uiState.value.watchProgressSource, - connectedProviderIds = TrackingProviderRegistry.connectedProviderIdsSnapshot(), + connectedProviderIds = connectedWatchedProviderIds(), ), forceSnapshot = true, ) @@ -1000,6 +1000,10 @@ object WatchedRepository { synchronized(accountScopeLock) { accountScope } + + private fun connectedWatchedProviderIds(): Set = + TrackingProviderRegistry.connectedWatchedProviders() + .mapTo(linkedSetOf()) { provider -> provider.providerId } } internal data class WatchedSnapshotMerge( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index 94b59127..85c0b50c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -12,16 +12,13 @@ import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.player.PlayerPlaybackSnapshot import com.nuvio.app.features.profiles.ProfileRepository -import com.nuvio.app.features.simkl.SimklProgressRepository +import com.nuvio.app.features.tracking.TrackingProgressProvider import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.tracking.TrackingProviderRegistry +import com.nuvio.app.features.tracking.TrackingSettingsRepository import com.nuvio.app.features.tracking.WatchProgressSource import com.nuvio.app.features.tracking.effectiveWatchProgressSource -import com.nuvio.app.features.trakt.TraktAuthRepository -import com.nuvio.app.features.trakt.TraktProgressRepository -import com.nuvio.app.features.trakt.TraktSettingsRepository -import com.nuvio.app.features.trakt.isTraktCompatibleId -import com.nuvio.app.features.trakt.resolveEffectiveContentId +import com.nuvio.app.features.tracking.providerId import com.nuvio.app.features.watching.application.WatchingActions import com.nuvio.app.features.watching.sync.ProgressDeltaEvent import com.nuvio.app.features.watching.sync.ProgressSyncRecord @@ -267,18 +264,13 @@ object WatchProgressRepository { internal var syncAdapter: ProgressSyncAdapter = SupabaseProgressSyncAdapter init { - syncScope.launch { - TraktProgressRepository.uiState.collectLatest { - if (shouldUseTraktProgress()) { - publish() - } - } - } - - syncScope.launch { - SimklProgressRepository.uiState.collectLatest { - if (activeSource == WatchProgressSource.SIMKL) { - publish() + ensureTrackingProvidersRegistered() + TrackingProviderRegistry.progressProviders().forEach { provider -> + syncScope.launch { + provider.changes.collectLatest { + if (activeSource.providerId == provider.providerId) { + publish() + } } } } @@ -294,14 +286,13 @@ object WatchProgressRepository { fun ensureLoaded() { ensureTrackingProvidersRegistered() TrackingProviderRegistry.ensureLoaded() - TraktSettingsRepository.ensureLoaded() - TraktProgressRepository.ensureLoaded() - SimklProgressRepository.ensureLoaded() + TrackingSettingsRepository.ensureLoaded() + TrackingProviderRegistry.progressProviders().forEach(TrackingProgressProvider::ensureLoaded) if (!hasLoaded) { updateActiveSource( effectiveWatchProgressSource( - requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource, - isProviderAuthenticated = TrackingProviderRegistry::isAuthenticated, + requestedSource = TrackingSettingsRepository.uiState.value.watchProgressSource, + isProviderAuthenticated = ::isProgressProviderAvailable, ), ) loadFromDisk(ProfileRepository.activeProfileId) @@ -310,15 +301,14 @@ object WatchProgressRepository { fun onProfileChanged(profileId: Int) { if (profileId == currentProfileId && hasLoaded) return - TraktSettingsRepository.onProfileChanged() updateActiveSource( effectiveWatchProgressSource( - requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource, - isProviderAuthenticated = TrackingProviderRegistry::isAuthenticated, + requestedSource = TrackingSettingsRepository.uiState.value.watchProgressSource, + isProviderAuthenticated = ::isProgressProviderAvailable, ), ) loadFromDisk(profileId) - TraktProgressRepository.onProfileChanged() + TrackingProviderRegistry.progressProviders().forEach(TrackingProgressProvider::onProfileChanged) } fun clearLocalState() { @@ -339,8 +329,7 @@ object WatchProgressRepository { lastSuccessfulPushEpochMs = 0L deltaCursorEventId = 0L deltaInitialized = false - TraktProgressRepository.clearLocalState() - TraktSettingsRepository.clearLocalState() + TrackingProviderRegistry.progressProviders().forEach(TrackingProgressProvider::clearLocalState) _uiState.value = WatchProgressUiState() } @@ -415,9 +404,8 @@ object WatchProgressRepository { internal fun activateSource(source: WatchProgressSource) { TrackingProviderRegistry.ensureLoaded() - TraktSettingsRepository.ensureLoaded() - TraktProgressRepository.ensureLoaded() - SimklProgressRepository.ensureLoaded() + TrackingSettingsRepository.ensureLoaded() + TrackingProviderRegistry.progressProviders().forEach(TrackingProgressProvider::ensureLoaded) if (!hasLoaded) { loadFromDisk(ProfileRepository.activeProfileId) } @@ -428,13 +416,9 @@ object WatchProgressRepository { updateActiveSource(source) cancelMetadataResolution(resetProviderHistory = false) - when (source) { - WatchProgressSource.TRAKT -> TraktProgressRepository.clearLocalState() - WatchProgressSource.NUVIO_SYNC -> hasLoadedNuvioRemoteProgress = false - WatchProgressSource.SIMKL -> Unit - } + activeProgressProvider()?.onActivated() ?: run { hasLoadedNuvioRemoteProgress = false } publish() - if (source == WatchProgressSource.NUVIO_SYNC) { + if (activeProgressProvider()?.providesCompleteMetadata != true) { resolveRemoteMetadata() } } @@ -455,80 +439,74 @@ object WatchProgressRepository { } activateSource(source) - return when (source) { - WatchProgressSource.TRAKT -> refreshTraktSource( + activeProgressProvider()?.let { provider -> + return refreshProviderSource( + provider = provider, profileId = profileId, operationGeneration = operationGeneration, sourceChanged = sourceChanged, force = force, ) - - WatchProgressSource.NUVIO_SYNC -> refreshNuvioSource( - profileId = profileId, - operationGeneration = operationGeneration, - force = force, - ) - - WatchProgressSource.SIMKL -> refreshSimklSource( - profileId = profileId, - operationGeneration = operationGeneration, - ) } + return refreshNuvioSource( + profileId = profileId, + operationGeneration = operationGeneration, + force = force, + ) } - private suspend fun refreshSimklSource( - profileId: Int, - operationGeneration: Long, - ): Boolean { - if (!TrackingProviderRegistry.isAuthenticated(TrackingProviderId.SIMKL)) { - log.d { "Skipping Simkl progress refresh because Simkl is not authenticated" } - return false - } - return try { - SimklProgressRepository.refreshNow() - if (isActiveOperation(profileId, operationGeneration) && activeSource == WatchProgressSource.SIMKL) { - publish() - } - val state = SimklProgressRepository.uiState.value - state.hasLoadedRemoteProgress && state.errorMessage == null - } catch (error: CancellationException) { - throw error - } catch (error: Throwable) { - log.e(error) { "Failed to refresh Simkl watch progress" } - false - } - } - - private suspend fun refreshTraktSource( + private suspend fun refreshProviderSource( + provider: TrackingProgressProvider, profileId: Int, operationGeneration: Long, sourceChanged: Boolean, force: Boolean, ): Boolean { - if (!TraktAuthRepository.isAuthenticated.value) { - log.d { "Skipping Trakt progress refresh because Trakt is not authenticated" } + if (!isProgressProviderAvailable(provider.providerId)) { + log.d { "Skipping ${provider.providerId.storageId} progress refresh because it is unavailable" } return false } - return try { - if (force || sourceChanged) { - TraktProgressRepository.invalidateAndRefresh() - } else { - TraktProgressRepository.refreshNow() - } - if (isActiveOperation(profileId, operationGeneration) && activeSource == WatchProgressSource.TRAKT) { + provider.refresh(force = force, sourceChanged = sourceChanged) + if ( + isActiveOperation(profileId, operationGeneration) && + activeSource.providerId == provider.providerId + ) { publish() } - val state = TraktProgressRepository.uiState.value + val state = provider.snapshot() state.hasLoadedRemoteProgress && state.errorMessage == null } catch (error: CancellationException) { throw error } catch (error: Throwable) { - log.e(error) { "Failed to refresh Trakt watch progress" } + log.e(error) { "Failed to refresh ${provider.providerId.storageId} watch progress" } false } } + private fun activeProgressProvider(): TrackingProgressProvider? = + activeSource.providerId?.let(TrackingProviderRegistry::progressProvider) + + private fun isProgressProviderAvailable(providerId: TrackingProviderId): Boolean = + TrackingProviderRegistry.progressProvider(providerId) != null && + TrackingProviderRegistry.isAuthenticated(providerId) + + private suspend fun removeProviderProgress( + provider: TrackingProgressProvider, + entries: Collection, + reason: String, + ) { + try { + provider.removeProgress(entries) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.e(error) { + "Failed to $reason from ${provider.providerId.storageId}" + } + } + } + private suspend fun refreshNuvioSource( profileId: Int, operationGeneration: Long, @@ -958,7 +936,7 @@ object WatchProgressRepository { } private fun retryMetadataResolutionWhenAddonMetaProvidersReady(state: AddonsUiState) { - if (!hasLoaded || shouldUseTraktProgress()) return + if (!hasLoaded || activeProgressProvider()?.providesCompleteMetadata == true) return val readiness = state.metadataProviderReadiness() if (!readiness.isReady) return @@ -1054,7 +1032,7 @@ object WatchProgressRepository { resolutionGeneration = resolutionGeneration, currentProviderFingerprint = currentReadiness.fingerprint.takeIf { currentReadiness.isReady }, ) - if (shouldRetry && hasLoaded && !shouldUseTraktProgress()) { + if (shouldRetry && hasLoaded && activeProgressProvider()?.providesCompleteMetadata != true) { resolveRemoteMetadata() } } @@ -1118,62 +1096,18 @@ object WatchProgressRepository { ensureLoaded() if (videoIds.isEmpty()) return - val useTraktProgress = shouldUseTraktProgress() - if (shouldUseSimklProgress()) { + activeProgressProvider()?.let { provider -> val entriesToRemove = currentEntries().filter { entry -> entry.videoId in videoIds && (parentMetaId == null || entry.parentMetaId == parentMetaId) } val locallyRemovedEntries = removeStoredLocalEntries(entriesToRemove) + provider.applyOptimisticRemoval(entriesToRemove) if (locallyRemovedEntries.isNotEmpty()) persist() publish() if (entriesToRemove.isNotEmpty()) { syncScope.launch { - SimklProgressRepository.removeProgress(entriesToRemove) - } - } - return - } - if (useTraktProgress) { - val entriesToRemove = currentEntries().filter { entry -> - entry.videoId in videoIds && - (parentMetaId == null || entry.parentMetaId == parentMetaId) - } - val locallyRemovedEntries = removeStoredLocalEntries(entriesToRemove) - if (parentMetaId == null) { - videoIds.forEach(TraktProgressRepository::applyOptimisticRemoval) - } else { - entriesToRemove - .distinctBy { entry -> - Triple(entry.parentMetaId, entry.seasonNumber, entry.episodeNumber) - } - .forEach { entry -> - TraktProgressRepository.applyOptimisticRemoval( - contentId = entry.parentMetaId, - seasonNumber = entry.seasonNumber, - episodeNumber = entry.episodeNumber, - ) - } - } - if (locallyRemovedEntries.isNotEmpty()) { - persist() - } - publish() - val traktEntriesToRemove = entriesToRemove.filter { entry -> entry.shouldAttemptTraktPlaybackDelete() } - if (traktEntriesToRemove.isNotEmpty()) { - syncScope.launch { - traktEntriesToRemove.forEach { entry -> - runCatching { - TraktProgressRepository.removeProgress( - contentId = entry.parentMetaId, - seasonNumber = entry.seasonNumber, - episodeNumber = entry.episodeNumber, - ) - }.onFailure { error -> - if (error is CancellationException) throw error - log.e(error) { "Failed to clear Trakt playback progress for ${entry.videoId}" } - } - } + removeProviderProgress(provider, entriesToRemove, "clear playback progress") } } return @@ -1199,7 +1133,6 @@ object WatchProgressRepository { val normalizedContentId = contentId.trim() if (normalizedContentId.isBlank()) return - val useTraktProgress = shouldUseTraktProgress() val entriesToRemove = currentEntries().filter { entry -> if (entry.parentMetaId != normalizedContentId) { false @@ -1211,42 +1144,13 @@ object WatchProgressRepository { } if (entriesToRemove.isEmpty()) return - if (shouldUseSimklProgress()) { + activeProgressProvider()?.let { provider -> val locallyRemovedEntries = removeStoredLocalEntries(entriesToRemove) + provider.applyOptimisticRemoval(entriesToRemove) if (locallyRemovedEntries.isNotEmpty()) persist() publish() syncScope.launch { - SimklProgressRepository.removeProgress(entriesToRemove) - } - return - } - - if (useTraktProgress) { - val locallyRemovedEntries = removeStoredLocalEntries(entriesToRemove) - TraktProgressRepository.applyOptimisticRemoval( - contentId = normalizedContentId, - seasonNumber = seasonNumber, - episodeNumber = episodeNumber, - ) - if (locallyRemovedEntries.isNotEmpty()) { - persist() - } - publish() - val shouldAttemptTraktDelete = entriesToRemove.any { entry -> entry.shouldAttemptTraktPlaybackDelete() } - if (!shouldAttemptTraktDelete) { - return - } - syncScope.launch { - runCatching { - TraktProgressRepository.removeProgress( - contentId = normalizedContentId, - seasonNumber = seasonNumber, - episodeNumber = episodeNumber, - ) - }.onFailure { error -> - if (error is CancellationException) throw error - log.e(error) { "Failed to remove Trakt watch progress" } - } + removeProviderProgress(provider, entriesToRemove, "remove playback progress") } return } @@ -1286,16 +1190,19 @@ object WatchProgressRepository { fun refreshEpisodeProgress(contentId: String, forceRefresh: Boolean = false) { ensureLoaded() - if (!shouldUseTraktProgress()) return + val provider = activeProgressProvider() ?: return syncScope.launch { runCatching { - TraktProgressRepository.refreshEpisodeProgress( + provider.refreshEpisodeProgress( contentId = contentId, forceRefresh = forceRefresh, ) }.onFailure { error -> if (error is CancellationException) throw error - log.w { "Failed to refresh Trakt episode progress for $contentId: ${error.message}" } + log.w { + "Failed to refresh ${provider.providerId.storageId} episode progress " + + "for $contentId: ${error.message}" + } } } } @@ -1318,16 +1225,11 @@ object WatchProgressRepository { return } - val useTraktProgress = shouldUseTraktProgress() - - // If Trakt is the active CW source and parentMetaId is not Trakt-resolvable - // but videoId contains a valid IMDB/TMDB, use the resolved ID to avoid - // duplicate CW entries (one local with garbage ID, one from Trakt with real ID). - val effectiveParentMetaId = if (useTraktProgress) { - resolveEffectiveContentId(session.parentMetaId, session.videoId) - } else { - session.parentMetaId - } + val progressProvider = activeProgressProvider() + val effectiveParentMetaId = progressProvider?.normalizeParentContentId( + parentContentId = session.parentMetaId, + videoId = session.videoId, + ) ?: session.parentMetaId val candidateEntry = WatchProgressEntry( contentType = session.contentType, @@ -1374,9 +1276,7 @@ object WatchProgressRepository { upsertLocalEntry(entry) markProgressDirty(entry) - if (useTraktProgress) { - TraktProgressRepository.applyOptimisticProgress(entry) - } + progressProvider?.applyOptimisticProgress(entry) publish() if (persist) persist() if (entry.needsRemoteMetadataEnrichment()) { @@ -1385,7 +1285,12 @@ object WatchProgressRepository { if (syncRemote) { pushScrobbleToServer(entry = entry, profileId = targetProfileId) } - if (shouldCascadeCompletedProgressToWatchedHistory(entry, useTraktProgress)) { + if ( + shouldCascadeCompletedProgressToWatchedHistory( + entry = entry, + providerOwnsCompletedHistory = progressProvider?.ownsCompletedHistoryProjection == true, + ) + ) { WatchingActions.onProgressEntryUpdated(entry, syncRemote = syncRemote) } } @@ -1447,7 +1352,7 @@ object WatchProgressRepository { } private fun pushDeleteToServer(entries: Collection) { - if (activeSource != WatchProgressSource.NUVIO_SYNC) return + if (activeSource.providerId != null) return val profileId = currentProfileId accountScopeSnapshot().launch { runCatching { @@ -1462,11 +1367,10 @@ object WatchProgressRepository { private fun publish() { val entries = currentEntries() val sortedEntries = entries.sortedByDescending { it.lastUpdatedEpochMs } - val hasLoadedRemoteProgress = when (activeSource) { - WatchProgressSource.TRAKT -> TraktProgressRepository.uiState.value.hasLoadedRemoteProgress - WatchProgressSource.SIMKL -> SimklProgressRepository.uiState.value.hasLoadedRemoteProgress - WatchProgressSource.NUVIO_SYNC -> hasLoadedNuvioRemoteProgress - } + val hasLoadedRemoteProgress = activeProgressProvider() + ?.snapshot() + ?.hasLoadedRemoteProgress + ?: hasLoadedNuvioRemoteProgress _uiState.value = WatchProgressUiState( entries = sortedEntries, hasLoadedRemoteProgress = hasLoadedRemoteProgress, @@ -1558,12 +1462,6 @@ object WatchProgressRepository { ) } - private fun shouldUseTraktProgress(): Boolean = - activeSource == WatchProgressSource.TRAKT - - private fun shouldUseSimklProgress(): Boolean = - activeSource == WatchProgressSource.SIMKL - private fun accountScopeSnapshot(): CoroutineScope = synchronized(accountScopeLock) { accountScope } @@ -1573,9 +1471,6 @@ object WatchProgressRepository { _activeSourceState.value = source } - private fun WatchProgressEntry.shouldAttemptTraktPlaybackDelete(): Boolean = - isTraktCompatibleId(parentMetaId) - private fun removeStoredLocalEntries(entries: Collection): List = synchronized(entriesLock) { val targetKeys = entries.mapTo(mutableSetOf()) { entry -> entry.resolvedProgressKey() } @@ -1594,34 +1489,11 @@ object WatchProgressRepository { } private fun currentEntries(): List { - return when (activeSource) { - WatchProgressSource.TRAKT -> { - // Merge Trakt remote progress with local-only entries that use - // non-Trakt-compatible IDs (kitsu:, mal:, anilist:, etc.). - // Trakt will never return these IDs, so they must come from local storage. - val traktItems = TraktProgressRepository.uiState.value.entries - val localNonTraktItems = localEntriesSnapshot().filter { - !isTraktCompatibleId(it.parentMetaId) - } - if (localNonTraktItems.isEmpty()) { - traktItems - } else { - val traktKeys = traktItems.mapTo(mutableSetOf()) { entry -> entry.resolvedProgressKey() } - val merged = traktItems.toMutableList() - localNonTraktItems.forEach { localItem -> - if (localItem.resolvedProgressKey() !in traktKeys) { - merged.add(localItem) - } - } - merged - } - } - WatchProgressSource.SIMKL -> mergeTrackerProgressEntries( - remoteEntries = SimklProgressRepository.uiState.value.entries, - localEntries = localEntriesSnapshot(), - ) - WatchProgressSource.NUVIO_SYNC -> localEntriesSnapshot() - } + val provider = activeProgressProvider() ?: return localEntriesSnapshot() + return mergeTrackerProgressEntries( + remoteEntries = provider.snapshot().entries, + localEntries = localEntriesSnapshot().filter(provider::shouldRetainLocalEntry), + ) } private fun localEntriesSnapshot(): List = @@ -1752,7 +1624,7 @@ object WatchProgressRepository { } fun isDroppedShow(contentId: String): Boolean { - return shouldUseTraktProgress() && TraktProgressRepository.isShowHiddenFromProgress(contentId) + return activeProgressProvider()?.isHiddenFromProgress(contentId) == true } private fun AddonsUiState.metadataProviderReadiness(): MetadataProviderReadiness { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt index f58b7807..2961bc44 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt @@ -175,8 +175,8 @@ internal fun shouldReplaceProgressSnapshotEntry( internal fun shouldCascadeCompletedProgressToWatchedHistory( entry: WatchProgressEntry, - isUsingTraktProgress: Boolean, -): Boolean = !isUsingTraktProgress && entry.normalizedCompletion().isCompleted + providerOwnsCompletedHistory: Boolean, +): Boolean = !providerOwnsCompletedHistory && entry.normalizedCompletion().isCompleted internal fun String?.isSeriesTypeForContinueWatching(): Boolean = equals("series", ignoreCase = true) || equals("tv", ignoreCase = true) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt index 512e8f67..224a1746 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt @@ -474,7 +474,11 @@ object WatchProgressSourceCoordinator { requestedSource = requestedSource, effectiveSource = effectiveWatchProgressSource( requestedSource = requestedSource, - isProviderAuthenticated = { providerId -> providerId in connectedProviderIds }, + isProviderAuthenticated = { providerId -> + providerId in connectedProviderIds && + TrackingProviderRegistry.progressProvider(providerId) != null && + TrackingProviderRegistry.watchedProvider(providerId) != null + }, ), isNuvioAuthenticated = authState is AuthState.Authenticated && !authState.isAnonymous, ) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt index 72fcca6d..02bd0e6a 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt @@ -403,7 +403,7 @@ class WatchProgressRulesTest { } @Test - fun `completed progress does not cascade to watched history while Trakt progress is active`() { + fun `completed progress does not cascade when provider owns watched projection`() { val completed = entry( videoId = "movie-complete", isCompleted = true, @@ -413,19 +413,19 @@ class WatchProgressRulesTest { assertFalse( shouldCascadeCompletedProgressToWatchedHistory( entry = completed, - isUsingTraktProgress = true, + providerOwnsCompletedHistory = true, ), ) assertTrue( shouldCascadeCompletedProgressToWatchedHistory( entry = completed, - isUsingTraktProgress = false, + providerOwnsCompletedHistory = false, ), ) assertFalse( shouldCascadeCompletedProgressToWatchedHistory( entry = inProgress, - isUsingTraktProgress = false, + providerOwnsCompletedHistory = false, ), ) } From e7349dd28f00cb3775d868d2402f39647cd444b0 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:44:31 +0530 Subject: [PATCH 13/59] refactor(tracking): remove shared feature coupling --- .../composeResources/values/strings.xml | 2 +- .../core/storage/LocalAccountDataCleaner.kt | 4 ++-- .../app/core/sync/ProfileSettingsSync.kt | 10 +++++----- .../com/nuvio/app/core/sync/SyncManager.kt | 12 +++++------ .../features/details/MetaDetailsRepository.kt | 20 +++++++++---------- .../app/features/details/MetaDetailsScreen.kt | 10 +++++----- .../com/nuvio/app/features/home/HomeScreen.kt | 16 +++++++-------- .../library/LibraryDisplaySettings.kt | 9 ++++++--- .../features/library/LibrarySavedContent.kt | 6 +++--- .../EpisodeReleaseNotificationsRepository.kt | 4 ++-- .../features/profiles/ProfileRepository.kt | 4 ++-- .../app/features/tracking/TrackingSettings.kt | 4 ++++ .../app/features/watched/WatchedModels.kt | 4 ++-- .../library/LibraryDisplaySettingsTest.kt | 18 +++++++++++++++-- .../app/features/watched/WatchedModelsTest.kt | 4 ++-- .../watching/application/WatchingStateTest.kt | 4 ++-- 16 files changed, 76 insertions(+), 55 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index b170f2b4..a4501dc0 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1657,7 +1657,7 @@ Recently added Title A–Z Title Z–A - Trakt order + Tracker order Cloud Saved Library diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt index 3fd89740..9a4b0039 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt @@ -29,7 +29,7 @@ import com.nuvio.app.features.streams.StreamBadgeSettingsRepository import com.nuvio.app.features.streams.StreamLaunchStore import com.nuvio.app.features.streams.StreamsRepository import com.nuvio.app.features.tracking.TrackingProviderRegistry -import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.tracking.TrackingSettingsRepository import com.nuvio.app.core.ui.CardDepthStyleRepository import com.nuvio.app.core.ui.PosterCardStyleRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository @@ -70,7 +70,7 @@ internal object LocalAccountDataCleaner { PosterCardStyleRepository.clearLocalState() CardDepthStyleRepository.clearLocalState() TrackingProviderRegistry.clearLocalState() - TraktSettingsRepository.clearLocalState() + TrackingSettingsRepository.clearLocalState() PlayerSettingsRepository.clearLocalState() StreamBadgeSettingsRepository.clearLocalState() P2pSettingsRepository.clearLocalState() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt index 31322ad1..7e6a5472 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt @@ -30,7 +30,7 @@ import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.trakt.TraktCommentsStorage import com.nuvio.app.features.trakt.TraktCommentsSettings import com.nuvio.app.features.trakt.TraktSettingsStorage -import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.tracking.TrackingSettingsRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import io.github.jan.supabase.postgrest.postgrest @@ -184,7 +184,7 @@ object ProfileSettingsSync { MetaScreenSettingsRepository.uiState.map { "meta" }, CollectionMobileSettingsRepository.uiState.map { "collection_mobile_settings" }, ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" }, - TraktSettingsRepository.uiState.map { "trakt_settings" }, + TrackingSettingsRepository.uiState.map { "trakt_settings" }, TraktCommentsSettings.enabled.map { "trakt_comments" }, EpisodeReleaseNotificationsRepository.uiState.map { "episode_release_alerts" }, ) @@ -278,7 +278,7 @@ object ProfileSettingsSync { ContinueWatchingPreferencesRepository.onProfileChanged() TraktSettingsStorage.savePayload(blob.features.traktSettingsPayload) - TraktSettingsRepository.onProfileChanged() + TrackingSettingsRepository.onProfileChanged() TraktCommentsStorage.replaceFromSyncPayload(blob.features.traktCommentsSettings) TraktCommentsSettings.onProfileChanged() @@ -298,7 +298,7 @@ object ProfileSettingsSync { MetaScreenSettingsRepository.ensureLoaded() CollectionMobileSettingsRepository.ensureLoaded() ContinueWatchingPreferencesRepository.ensureLoaded() - TraktSettingsRepository.ensureLoaded() + TrackingSettingsRepository.ensureLoaded() TraktCommentsSettings.ensureLoaded() EpisodeReleaseNotificationsRepository.ensureLoaded() } @@ -321,7 +321,7 @@ object ProfileSettingsSync { "meta=${MetaScreenSettingsRepository.uiState.value}", "collection_mobile_settings=${CollectionMobileSettingsRepository.uiState.value}", "continue=${ContinueWatchingPreferencesRepository.uiState.value}", - "trakt_settings=${TraktSettingsRepository.uiState.value}", + "trakt_settings=${TrackingSettingsRepository.uiState.value}", "trakt_comments=${TraktCommentsSettings.enabled.value}", "episode_release_alerts=${EpisodeReleaseNotificationsRepository.uiState.value.isEnabled}", ).joinToString(separator = "||") diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt index 7a635833..87727fc2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt @@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.build.AppFeaturePolicy +import com.nuvio.app.core.time.EpisodeReleaseDatePlatform import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.collection.CollectionSyncService import com.nuvio.app.features.home.HomeCatalogSettingsSyncService @@ -11,9 +12,8 @@ import com.nuvio.app.features.library.LibrarySourceMode import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.profiles.ProfileRepository -import com.nuvio.app.features.trakt.TraktPlatformClock -import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.tracking.TrackingProviderRegistry +import com.nuvio.app.features.tracking.TrackingSettingsRepository import com.nuvio.app.features.tracking.WatchProgressSource import com.nuvio.app.features.tracking.effectiveLibrarySourceMode import com.nuvio.app.features.tracking.effectiveWatchProgressSource @@ -305,7 +305,7 @@ object SyncManager { private fun hasRecentFullPull(profileId: Int): Boolean = synchronized(pullStateLock) { lastFullPullProfileId == profileId && - TraktPlatformClock.nowEpochMs() - lastFullPullAtMs < FOREGROUND_PULL_MIN_INTERVAL_MS + EpisodeReleaseDatePlatform.nowEpochMs() - lastFullPullAtMs < FOREGROUND_PULL_MIN_INTERVAL_MS } private suspend fun pullForegroundForProfile(profileId: Int) { @@ -383,7 +383,7 @@ object SyncManager { } if (syncResult.succeeded) { synchronized(pullStateLock) { - lastFullPullAtMs = TraktPlatformClock.nowEpochMs() + lastFullPullAtMs = EpisodeReleaseDatePlatform.nowEpochMs() lastFullPullProfileId = profileId } } else { @@ -429,9 +429,9 @@ object SyncManager { } TrackingProviderRegistry.ensureLoaded() - TraktSettingsRepository.ensureLoaded() + TrackingSettingsRepository.ensureLoaded() - val settings = TraktSettingsRepository.uiState.value + val settings = TrackingSettingsRepository.uiState.value val shouldPullLibrary = effectiveLibrarySourceMode( requestedSource = settings.librarySourceMode, isProviderAuthenticated = TrackingProviderRegistry::isAuthenticated, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt index 687820da..d1820f92 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt @@ -16,7 +16,7 @@ import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktConnectionMode import com.nuvio.app.features.trakt.TraktRelatedRepository -import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.tracking.TrackingSettingsRepository import com.nuvio.app.features.trakt.shouldUseTraktMoreLikeThis import com.nuvio.app.features.watchprogress.CurrentDateProvider import kotlinx.coroutines.CancellationException @@ -408,15 +408,15 @@ object MetaDetailsRepository { fallbackItemId: String, fallbackItemType: String, ): MetaDetails { - TraktSettingsRepository.ensureLoaded() + TrackingSettingsRepository.ensureLoaded() TraktAuthRepository.ensureLoaded() TmdbSettingsRepository.ensureLoaded() - val traktSettings = TraktSettingsRepository.uiState.value + val trackingSettings = TrackingSettingsRepository.uiState.value val isTraktAuthenticated = TraktAuthRepository.uiState.value.mode == TraktConnectionMode.CONNECTED val shouldUseTrakt = shouldUseTraktMoreLikeThis( isAuthenticated = isTraktAuthenticated, - source = traktSettings.moreLikeThisSource, + source = trackingSettings.moreLikeThisSource, ) && supportsMoreLikeThis(meta, fallbackItemType) if (shouldUseTrakt) { @@ -466,32 +466,32 @@ object MetaDetailsRepository { } private fun shouldApplyMoreLikeThisSource(meta: MetaDetails): Boolean { - TraktSettingsRepository.ensureLoaded() + TrackingSettingsRepository.ensureLoaded() TraktAuthRepository.ensureLoaded() TmdbSettingsRepository.ensureLoaded() - val traktSettings = TraktSettingsRepository.uiState.value + val trackingSettings = TrackingSettingsRepository.uiState.value val isTraktAuthenticated = TraktAuthRepository.uiState.value.mode == TraktConnectionMode.CONNECTED val tmdbSettings = TmdbSettingsRepository.snapshot() return shouldUseTraktMoreLikeThis( isAuthenticated = isTraktAuthenticated, - source = traktSettings.moreLikeThisSource, + source = trackingSettings.moreLikeThisSource, ) || !tmdbSettings.enabled || !tmdbSettings.useMoreLikeThis || meta.moreLikeThisSource == null && meta.moreLikeThis.isNotEmpty() } private fun buildMetaScreenSettingsFingerprint( settings: com.nuvio.app.features.mdblist.MdbListSettings, ): String { - TraktSettingsRepository.ensureLoaded() + TrackingSettingsRepository.ensureLoaded() TraktAuthRepository.ensureLoaded() TmdbSettingsRepository.ensureLoaded() val providers = settings.enabledProvidersInPriorityOrder().joinToString(",") - val traktSettings = TraktSettingsRepository.uiState.value + val trackingSettings = TrackingSettingsRepository.uiState.value val traktAuthMode = TraktAuthRepository.uiState.value.mode val tmdbSettings = TmdbSettingsRepository.snapshot() return buildString { append("${settings.enabled}:${settings.apiKey.trim()}:$providers") - append("|more_like=${traktSettings.moreLikeThisSource}:$traktAuthMode") + append("|more_like=${trackingSettings.moreLikeThisSource}:$traktAuthMode") append("|tmdb=${tmdbSettings.enabled}:${tmdbSettings.useMoreLikeThis}:${tmdbSettings.hasApiKey}:${tmdbSettings.language}") } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index c3b26978..a4932a04 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -116,7 +116,7 @@ import com.nuvio.app.features.trakt.TraktCommentsRepository import com.nuvio.app.features.trakt.TraktCommentsSettings import com.nuvio.app.features.trakt.TraktConnectionMode import com.nuvio.app.features.tracking.TrackingLibraryTab -import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.tracking.TrackingSettingsRepository import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.trailer.TrailerPlaybackResolver import com.nuvio.app.features.trailer.TrailerPlaybackSource @@ -165,9 +165,9 @@ fun MetaDetailsScreen( TraktAuthRepository.ensureLoaded() TraktAuthRepository.uiState }.collectAsStateWithLifecycle() - val traktSettingsUiState by remember { - TraktSettingsRepository.ensureLoaded() - TraktSettingsRepository.uiState + val trackingSettingsUiState by remember { + TrackingSettingsRepository.ensureLoaded() + TrackingSettingsRepository.uiState }.collectAsStateWithLifecycle() val tmdbSettingsUiState by remember { TmdbSettingsRepository.ensureLoaded() @@ -292,7 +292,7 @@ fun MetaDetailsScreen( id, displayedMeta?.id, uiState.isLoading, - traktSettingsUiState.moreLikeThisSource, + trackingSettingsUiState.moreLikeThisSource, traktAuthUiState.mode, tmdbSettingsUiState.enabled, tmdbSettingsUiState.useMoreLikeThis, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index be2bca10..5c3d30ef 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -50,7 +50,7 @@ import com.nuvio.app.features.home.components.HomeSkeletonRow import com.nuvio.app.features.home.components.HomeContinueWatchingSectionBottomPadding import com.nuvio.app.features.home.components.ContinueWatchingLayout import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL -import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.tracking.TrackingSettingsRepository import com.nuvio.app.features.tracking.WatchProgressSource import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap import com.nuvio.app.features.watched.WatchedItem @@ -147,9 +147,9 @@ fun HomeScreen( val effectiveWatchProgressSource by WatchProgressRepository.activeSourceState.collectAsStateWithLifecycle() val cloudLibraryUiState by CloudLibraryRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() - val traktSettingsUiState by remember { - TraktSettingsRepository.ensureLoaded() - TraktSettingsRepository.uiState + val trackingSettingsUiState by remember { + TrackingSettingsRepository.ensureLoaded() + TrackingSettingsRepository.uiState }.collectAsStateWithLifecycle() var observedOfflineState by remember { mutableStateOf(false) } @@ -189,7 +189,7 @@ fun HomeScreen( val effectiveWatchProgressEntries = remember( watchProgressUiState.entries, isTraktProgressActive, - traktSettingsUiState.continueWatchingDaysCap, + trackingSettingsUiState.continueWatchingDaysCap, ) { val filtered = if (isTraktProgressActive) { watchProgressUiState.entries.filter { !WatchProgressRepository.isDroppedShow(it.parentMetaId) } @@ -199,7 +199,7 @@ fun HomeScreen( filterEntriesForTraktContinueWatchingWindow( entries = filtered, isTraktProgressActive = isTraktProgressActive, - daysCap = traktSettingsUiState.continueWatchingDaysCap, + daysCap = trackingSettingsUiState.continueWatchingDaysCap, nowEpochMs = WatchProgressClock.nowEpochMs(), ) } @@ -227,12 +227,12 @@ fun HomeScreen( val recentNextUpSeedCandidates = remember( allNextUpSeedCandidates, isTraktProgressActive, - traktSettingsUiState.continueWatchingDaysCap, + trackingSettingsUiState.continueWatchingDaysCap, ) { filterHomeNextUpCandidatesForTraktContinueWatchingWindow( candidates = allNextUpSeedCandidates, isTraktProgressActive = isTraktProgressActive, - daysCap = traktSettingsUiState.continueWatchingDaysCap, + daysCap = trackingSettingsUiState.continueWatchingDaysCap, nowEpochMs = WatchProgressClock.nowEpochMs(), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettings.kt index d7f2b775..af31a942 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettings.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettings.kt @@ -85,7 +85,7 @@ internal data class LibraryVerticalProjection( ) internal fun availableLibrarySortOptions(sourceMode: LibrarySourceMode): List = - if (sourceMode == LibrarySourceMode.TRAKT) { + if (sourceMode.isRemoteTrackingSource) { LibrarySortOption.entries } else { LibrarySortOption.entries.filterNot { it == LibrarySortOption.DEFAULT } @@ -149,8 +149,8 @@ internal fun buildLibraryVerticalProjection( selectedType: String?, sortOption: LibrarySortOption, ): LibraryVerticalProjection { - val availableSections = if (sourceMode == LibrarySourceMode.TRAKT) sections else emptyList() - val selectedSection = if (sourceMode == LibrarySourceMode.TRAKT) { + val availableSections = if (sourceMode.isRemoteTrackingSource) sections else emptyList() + val selectedSection = if (sourceMode.isRemoteTrackingSource) { sections.firstOrNull { it.type == selectedSectionKey } ?: sections.firstOrNull() } else { null @@ -244,6 +244,9 @@ private fun libraryDisplayItemKey(item: LibraryItem): String = private fun String.normalizedLibraryType(): String = trim().lowercase() +internal val LibrarySourceMode.isRemoteTrackingSource: Boolean + get() = this != LibrarySourceMode.LOCAL + @Serializable private data class StoredLibraryDisplaySettings( @SerialName("layout_mode") val layoutMode: String = LibraryLayoutMode.HORIZONTAL.name, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibrarySavedContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibrarySavedContent.kt index b5fc5c46..d3a9d6dd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibrarySavedContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibrarySavedContent.kt @@ -27,7 +27,7 @@ import nuvio.composeapp.generated.resources.library_sort_added_asc import nuvio.composeapp.generated.resources.library_sort_added_desc import nuvio.composeapp.generated.resources.library_sort_title_asc import nuvio.composeapp.generated.resources.library_sort_title_desc -import nuvio.composeapp.generated.resources.library_sort_trakt_order +import nuvio.composeapp.generated.resources.library_sort_provider_order import org.jetbrains.compose.resources.stringResource @Composable @@ -48,7 +48,7 @@ internal fun LibrarySavedControls( modifier = modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - if (layoutMode == LibraryLayoutMode.VERTICAL && sourceMode == LibrarySourceMode.TRAKT) { + if (layoutMode == LibraryLayoutMode.VERTICAL && sourceMode.isRemoteTrackingSource) { val selectedSection = verticalProjection.availableSections .firstOrNull { section -> section.type == verticalProjection.selectedSectionKey } NuvioDropdownChip( @@ -158,7 +158,7 @@ internal fun LazyItemScope.libraryContentTransitionModifier(): Modifier = @Composable private fun librarySortOptionLabel(option: LibrarySortOption): String = when (option) { - LibrarySortOption.DEFAULT -> stringResource(Res.string.library_sort_trakt_order) + LibrarySortOption.DEFAULT -> stringResource(Res.string.library_sort_provider_order) LibrarySortOption.ADDED_DESC -> stringResource(Res.string.library_sort_added_desc) LibrarySortOption.ADDED_ASC -> stringResource(Res.string.library_sort_added_asc) LibrarySortOption.TITLE_ASC -> stringResource(Res.string.library_sort_title_asc) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsRepository.kt index b7bb198f..276fb287 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsRepository.kt @@ -8,7 +8,7 @@ import com.nuvio.app.features.library.LibraryItem import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.library.LibraryUiState import com.nuvio.app.features.profiles.ProfileRepository -import com.nuvio.app.features.trakt.TraktPlatformClock +import com.nuvio.app.core.time.EpisodeReleaseDatePlatform import com.nuvio.app.features.watchprogress.CurrentDateProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -206,7 +206,7 @@ object EpisodeReleaseNotificationsRepository { } val request = EpisodeReleaseNotificationRequest( - requestId = "episode-release-test-${ProfileRepository.activeProfileId}-${TraktPlatformClock.nowEpochMs()}", + requestId = "episode-release-test-${ProfileRepository.activeProfileId}-${EpisodeReleaseDatePlatform.nowEpochMs()}", notificationTitle = target.name, notificationBody = getString(Res.string.notifications_test_preview_body), releaseDateIso = CurrentDateProvider.todayIsoDate(), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index 62d1b417..70c0ebd0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -27,7 +27,7 @@ import com.nuvio.app.features.search.SearchHistoryRepository import com.nuvio.app.features.settings.ThemeSettingsRepository import com.nuvio.app.features.streams.StreamBadgeSettingsRepository import com.nuvio.app.features.tracking.TrackingProviderRegistry -import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.tracking.TrackingSettingsRepository import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository @@ -155,7 +155,7 @@ object ProfileRepository { ) persist() WatchedRepository.onProfileChanged(profileIndex) - TraktSettingsRepository.onProfileChanged() + TrackingSettingsRepository.onProfileChanged() ensureTrackingProvidersRegistered() TrackingProviderRegistry.onProfileChanged() LibraryRepository.onProfileChanged(profileIndex) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingSettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingSettings.kt index 3402c782..9756a934 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingSettings.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingSettings.kt @@ -21,6 +21,10 @@ object TrackingSettingsRepository { fun ensureLoaded() = TraktSettingsRepository.ensureLoaded() + fun onProfileChanged() = TraktSettingsRepository.onProfileChanged() + + fun clearLocalState() = TraktSettingsRepository.clearLocalState() + fun setLibrarySourceMode(source: LibrarySourceMode) = TraktSettingsRepository.setLibrarySourceMode(source) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt index 4aae9a59..2aab015a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt @@ -1,7 +1,7 @@ package com.nuvio.app.features.watched +import com.nuvio.app.core.time.parseZonedIsoDateTimeToEpochMs import com.nuvio.app.features.home.MetaPreview -import com.nuvio.app.features.trakt.TraktPlatformClock import com.nuvio.app.features.watching.domain.WatchingContentRef import com.nuvio.app.features.watching.domain.watchedKey import kotlinx.serialization.Serializable @@ -72,7 +72,7 @@ internal fun normalizeWatchedMarkedAtEpochMs(value: Long): Long { append(second.toString().padStart(2, '0')) append('Z') } - return TraktPlatformClock.parseIsoDateTimeToEpochMs(iso) ?: value + return parseZonedIsoDateTimeToEpochMs(iso) ?: value } fun watchedItemKey( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsTest.kt index bbfdf90b..79c4e076 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsTest.kt @@ -6,7 +6,7 @@ import kotlin.test.assertEquals class LibraryDisplaySettingsTest { @Test - fun `local default resolves to recently added while Trakt uses rank order`() { + fun `local default resolves to recently added while remote trackers preserve provider order`() { assertEquals( LibrarySortOption.ADDED_DESC, effectiveLibrarySortOption(LibrarySortOption.DEFAULT, LibrarySourceMode.LOCAL), @@ -15,6 +15,10 @@ class LibraryDisplaySettingsTest { LibrarySortOption.DEFAULT, effectiveLibrarySortOption(LibrarySortOption.DEFAULT, LibrarySourceMode.TRAKT), ) + assertEquals( + LibrarySortOption.DEFAULT, + effectiveLibrarySortOption(LibrarySortOption.DEFAULT, LibrarySourceMode.SIMKL), + ) val input = listOf( item("ranked-second", savedAt = 3L, traktRank = 2), @@ -91,7 +95,7 @@ class LibraryDisplaySettingsTest { } @Test - fun `vertical Trakt projection selects one list then filters and sorts its items`() { + fun `vertical tracker projection selects one list then filters and sorts its items`() { val watchlist = LibrarySection( type = "watchlist", displayTitle = "Watchlist", @@ -120,6 +124,16 @@ class LibraryDisplaySettingsTest { assertEquals("movie", projection.selectedType) assertEquals(listOf("a", "z"), projection.entries.map { it.item.id }) assertEquals(listOf("watchlist", "watchlist"), projection.entries.map { it.section.type }) + + val simklProjection = buildLibraryVerticalProjection( + sections = listOf(watchlist), + sourceMode = LibrarySourceMode.SIMKL, + selectedSectionKey = null, + selectedType = null, + sortOption = LibrarySortOption.DEFAULT, + ) + assertEquals(listOf("watchlist"), simklProjection.availableSections.map { it.type }) + assertEquals("watchlist", simklProjection.selectedSectionKey) } @Test diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedModelsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedModelsTest.kt index 657fc8b2..0383e9f3 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedModelsTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedModelsTest.kt @@ -1,6 +1,6 @@ package com.nuvio.app.features.watched -import com.nuvio.app.features.trakt.TraktPlatformClock +import com.nuvio.app.core.time.parseZonedIsoDateTimeToEpochMs import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.tracking.WatchProgressSource import com.nuvio.app.features.tracking.providerId @@ -12,7 +12,7 @@ import kotlin.test.assertTrue class WatchedModelsTest { @Test fun `compact watched timestamp normalizes to epoch millis`() { - val expected = TraktPlatformClock.parseIsoDateTimeToEpochMs("2026-04-25T10:02:00Z") + val expected = parseZonedIsoDateTimeToEpochMs("2026-04-25T10:02:00Z") assertEquals(expected, normalizeWatchedMarkedAtEpochMs(20260425100200L)) } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt index afcf8f73..85d22929 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt @@ -1,6 +1,6 @@ package com.nuvio.app.features.watching.application -import com.nuvio.app.features.trakt.TraktPlatformClock +import com.nuvio.app.core.time.parseZonedIsoDateTimeToEpochMs import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback @@ -57,7 +57,7 @@ class WatchingStateTest { @Test fun `latest completed normalizes compact watched timestamps before sorting`() { - val expected = TraktPlatformClock.parseIsoDateTimeToEpochMs("2026-04-25T10:02:00Z") + val expected = parseZonedIsoDateTimeToEpochMs("2026-04-25T10:02:00Z") val result = WatchingState.latestCompletedBySeries( progressEntries = emptyList(), From b1a7f0757e7f0a9d1b1784ffe6b781f2374aec82 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:52:08 +0530 Subject: [PATCH 14/59] refactor(tracking): isolate progress provider policy --- .../com/nuvio/app/features/home/HomeScreen.kt | 200 ++++++------------ .../app/features/tracking/TrackingReads.kt | 14 ++ .../trakt/TraktTrackingProgressProvider.kt | 56 +++++ .../watchprogress/WatchProgressModels.kt | 1 - .../watchprogress/WatchProgressRepository.kt | 19 ++ .../watchprogress/WatchProgressRules.kt | 6 +- .../nuvio/app/features/home/HomeScreenTest.kt | 56 ++--- .../TraktTrackingProgressProviderTest.kt | 68 ++++++ .../watching/application/WatchingStateTest.kt | 4 +- .../watchprogress/WatchProgressRulesTest.kt | 16 -- 10 files changed, 240 insertions(+), 200 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktTrackingProgressProviderTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 5c3d30ef..53c91580 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -49,10 +49,8 @@ import com.nuvio.app.features.home.components.HomeSkeletonHero import com.nuvio.app.features.home.components.HomeSkeletonRow import com.nuvio.app.features.home.components.HomeContinueWatchingSectionBottomPadding import com.nuvio.app.features.home.components.ContinueWatchingLayout -import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL import com.nuvio.app.features.tracking.TrackingSettingsRepository import com.nuvio.app.features.tracking.WatchProgressSource -import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watched.episodePlaybackId @@ -76,7 +74,6 @@ import com.nuvio.app.features.watchprogress.WatchProgressClock import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watchprogress.WatchProgressSourceCoordinator -import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback import com.nuvio.app.features.watchprogress.buildContinueWatchingEpisodeSubtitle import com.nuvio.app.features.watchprogress.continueWatchingEntries import com.nuvio.app.features.watchprogress.toContinueWatchingItem @@ -97,7 +94,6 @@ import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import kotlinx.coroutines.yield -import com.nuvio.app.features.trakt.TraktEpisodeMappingService import com.nuvio.app.features.home.components.continueWatchingHeroViewportReserveHeight import com.nuvio.app.features.home.components.homeSectionHorizontalPaddingForWidth import com.nuvio.app.features.home.components.rememberContinueWatchingLayout @@ -180,60 +176,62 @@ fun HomeScreen( } } - val isTraktProgressActive = effectiveWatchProgressSource == WatchProgressSource.TRAKT + val progressProviderOwnsCompletedHistory = remember(effectiveWatchProgressSource) { + WatchProgressRepository.activeProviderOwnsCompletedHistoryProjection() + } + val continueWatchingCutoffEpochMs = remember( + effectiveWatchProgressSource, + trackingSettingsUiState.continueWatchingDaysCap, + ) { + WatchProgressRepository.activeProviderContinueWatchingCutoffEpochMs( + daysCap = trackingSettingsUiState.continueWatchingDaysCap, + nowEpochMs = WatchProgressClock.nowEpochMs(), + ) + } - val nextUpWatchedItems = remember(watchedUiState.items, isTraktProgressActive) { - if (isTraktProgressActive) emptyList() else watchedUiState.items + val nextUpWatchedItems = remember(watchedUiState.items, progressProviderOwnsCompletedHistory) { + if (progressProviderOwnsCompletedHistory) emptyList() else watchedUiState.items } val effectiveWatchProgressEntries = remember( watchProgressUiState.entries, - isTraktProgressActive, - trackingSettingsUiState.continueWatchingDaysCap, + continueWatchingCutoffEpochMs, ) { - val filtered = if (isTraktProgressActive) { - watchProgressUiState.entries.filter { !WatchProgressRepository.isDroppedShow(it.parentMetaId) } - } else { - watchProgressUiState.entries + val visibleProviderEntries = watchProgressUiState.entries.filterNot { entry -> + WatchProgressRepository.isDroppedShow(entry.parentMetaId) } - filterEntriesForTraktContinueWatchingWindow( - entries = filtered, - isTraktProgressActive = isTraktProgressActive, - daysCap = trackingSettingsUiState.continueWatchingDaysCap, - nowEpochMs = WatchProgressClock.nowEpochMs(), + filterEntriesForContinueWatchingWindow( + entries = visibleProviderEntries, + cutoffEpochMs = continueWatchingCutoffEpochMs, ) } val allNextUpSeedCandidates = remember( watchProgressUiState.entries, nextUpWatchedItems, - isTraktProgressActive, + progressProviderOwnsCompletedHistory, continueWatchingPreferences.upNextFromFurthestEpisode, ) { - val filteredEntries = if (isTraktProgressActive) { - watchProgressUiState.entries.filter { !WatchProgressRepository.isDroppedShow(it.parentMetaId) } - } else { - watchProgressUiState.entries + val visibleProviderEntries = watchProgressUiState.entries.filterNot { entry -> + WatchProgressRepository.isDroppedShow(entry.parentMetaId) } buildHomeNextUpSeedCandidates( - progressEntries = filteredEntries, + progressEntries = visibleProviderEntries, watchedItems = nextUpWatchedItems, - isTraktProgressActive = isTraktProgressActive, + providerOwnsCompletedHistory = progressProviderOwnsCompletedHistory, preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, nowEpochMs = WatchProgressClock.nowEpochMs(), + shouldUseProgressSeed = WatchProgressRepository::shouldUseAsNextUpSeed, ) } val recentNextUpSeedCandidates = remember( allNextUpSeedCandidates, - isTraktProgressActive, - trackingSettingsUiState.continueWatchingDaysCap, + continueWatchingCutoffEpochMs, ) { - filterHomeNextUpCandidatesForTraktContinueWatchingWindow( + filterHomeNextUpCandidatesForContinueWatchingWindow( candidates = allNextUpSeedCandidates, - isTraktProgressActive = isTraktProgressActive, - daysCap = trackingSettingsUiState.continueWatchingDaysCap, - nowEpochMs = WatchProgressClock.nowEpochMs(), + cutoffEpochMs = continueWatchingCutoffEpochMs, ) } @@ -329,10 +327,10 @@ fun HomeScreen( watchProgressUiState.hasLoadedRemoteProgress, watchedUiState.isLoaded, watchedUiState.hasLoadedRemoteItems, - isTraktProgressActive, + progressProviderOwnsCompletedHistory, ) { isHomeNextUpSeedSourceLoaded( - isTraktProgressActive = isTraktProgressActive, + providerOwnsCompletedHistory = progressProviderOwnsCompletedHistory, hasLoadedRemoteProgress = watchProgressUiState.hasLoadedRemoteProgress, hasLoadedWatchedItems = watchedUiState.isLoaded, hasLoadedRemoteWatchedItems = watchedUiState.hasLoadedRemoteItems, @@ -343,7 +341,7 @@ fun HomeScreen( continueWatchingPreferences.dismissedNextUpKeys, activeNextUpSeedContentIds, currentNextUpSeedByContentId, - isTraktProgressActive, + progressProviderOwnsCompletedHistory, watchProgressUiState.hasLoadedRemoteProgress, shouldValidateMissingNextUpSeeds, processedNextUpContentIds, @@ -373,7 +371,7 @@ fun HomeScreen( } } if ( - isTraktProgressActive && + progressProviderOwnsCompletedHistory && watchProgressUiState.hasLoadedRemoteProgress && cached.contentId in processedNextUpContentIds && cached.contentId !in nextUpItemsBySeries.keys @@ -386,7 +384,7 @@ fun HomeScreen( if (!cachedNextUpHasAired(cached) && !continueWatchingPreferences.showUnairedNextUp) { return@mapNotNull null } - if (isTraktProgressActive && WatchProgressRepository.isDroppedShow(cached.contentId)) { + if (WatchProgressRepository.isDroppedShow(cached.contentId)) { return@mapNotNull null } val item = cached.toContinueWatchingItem() ?: return@mapNotNull null @@ -398,9 +396,9 @@ fun HomeScreen( cached.contentId to (sortTimestamp to item) }.toMap() } - val cachedInProgressItems = remember(cachedSnapshots.second, isTraktProgressActive) { + val cachedInProgressItems = remember(cachedSnapshots.second, effectiveWatchProgressSource) { cachedSnapshots.second.mapNotNull { cached -> - if (isTraktProgressActive && WatchProgressRepository.isDroppedShow(cached.contentId)) { + if (WatchProgressRepository.isDroppedShow(cached.contentId)) { return@mapNotNull null } cached.resolvedProgressKey() to cached.toContinueWatchingItem() @@ -570,7 +568,7 @@ fun HomeScreen( ) { if ( !isHomeNextUpSeedSourceLoaded( - isTraktProgressActive = isTraktProgressActive, + providerOwnsCompletedHistory = progressProviderOwnsCompletedHistory, hasLoadedRemoteProgress = watchProgressUiState.hasLoadedRemoteProgress, hasLoadedWatchedItems = watchedUiState.isLoaded, hasLoadedRemoteWatchedItems = watchedUiState.hasLoadedRemoteItems, @@ -662,7 +660,7 @@ fun HomeScreen( preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp, dismissedNextUpKeys = continueWatchingPreferences.dismissedNextUpKeys, - isTraktProgressActive = isTraktProgressActive, + providerOwnsCompletedHistory = progressProviderOwnsCompletedHistory, ) } } catch (error: Throwable) { @@ -1098,8 +1096,6 @@ private const val HOME_CONTINUE_WATCHING_SECTION_KEY = "home_continue_watching" private const val HOME_UPCOMING_SECTION_KEY = "home_upcoming" internal const val HomeContinueWatchingMaxRecentProgressItems = 300 internal const val HomeNextUpInitialResolutionLimit = 32 -private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L -private const val OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS = 3L * 60L * 1000L private const val NEXT_UP_RESOLUTION_CONCURRENCY = 4 private const val MAX_NEXT_UP_RESOLUTION_RETRIES = 3 private const val NEXT_UP_RESOLUTION_RETRY_BASE_DELAY_MS = 1_500L @@ -1167,55 +1163,38 @@ internal fun planHomeNextUpResolutionCandidates( deferredCandidates = candidates.drop(HomeNextUpInitialResolutionLimit), ) -internal fun filterEntriesForTraktContinueWatchingWindow( +internal fun filterEntriesForContinueWatchingWindow( entries: List, - isTraktProgressActive: Boolean, - daysCap: Int, - nowEpochMs: Long, -): List { - if (!isTraktProgressActive) return entries - val normalizedDaysCap = normalizeTraktContinueWatchingDaysCap(daysCap) - if (normalizedDaysCap == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) return entries + cutoffEpochMs: Long?, +): List = cutoffEpochMs + ?.let { cutoff -> entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoff } } + ?: entries - val cutoffMs = nowEpochMs - (normalizedDaysCap.toLong() * MILLIS_PER_DAY) - return entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs } -} - -internal fun filterHomeNextUpCandidatesForTraktContinueWatchingWindow( +internal fun filterHomeNextUpCandidatesForContinueWatchingWindow( candidates: List, - isTraktProgressActive: Boolean, - daysCap: Int, - nowEpochMs: Long, -): List { - if (!isTraktProgressActive) return candidates - val normalizedDaysCap = normalizeTraktContinueWatchingDaysCap(daysCap) - if (normalizedDaysCap == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) return candidates - - val cutoffMs = nowEpochMs - (normalizedDaysCap.toLong() * MILLIS_PER_DAY) - return candidates.filter { candidate -> candidate.markedAtEpochMs >= cutoffMs } -} + cutoffEpochMs: Long?, +): List = cutoffEpochMs + ?.let { cutoff -> candidates.filter { candidate -> candidate.markedAtEpochMs >= cutoff } } + ?: candidates internal fun buildHomeNextUpSeedCandidates( progressEntries: List, watchedItems: List, - isTraktProgressActive: Boolean, + providerOwnsCompletedHistory: Boolean, preferFurthestEpisode: Boolean, nowEpochMs: Long, + shouldUseProgressSeed: (WatchProgressEntry, Long) -> Boolean = { entry, _ -> + entry.shouldUseAsCompletedSeedForContinueWatching() + }, ): List { val progressSeeds = progressEntries .asSequence() .filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() } .filter { entry -> entry.seasonNumber != null && entry.episodeNumber != null && entry.seasonNumber != 0 } .filter { entry -> !isMalformedNextUpSeedContentId(entry.parentMetaId) } - .filter { entry -> - if (isTraktProgressActive) { - shouldUseAsTraktNextUpSeed(entry = entry, nowEpochMs = nowEpochMs) - } else { - entry.shouldUseAsCompletedSeedForContinueWatching() - } - } + .filter { entry -> shouldUseProgressSeed(entry, nowEpochMs) } .toList() - val watchedSeeds = if (isTraktProgressActive) { + val watchedSeeds = if (providerOwnsCompletedHistory) { emptyList() } else { watchedItems.filter { item -> @@ -1265,12 +1244,12 @@ internal fun filterNextUpItemsByCurrentSeeds( } internal fun isHomeNextUpSeedSourceLoaded( - isTraktProgressActive: Boolean, + providerOwnsCompletedHistory: Boolean, hasLoadedRemoteProgress: Boolean, hasLoadedWatchedItems: Boolean, hasLoadedRemoteWatchedItems: Boolean, ): Boolean = hasLoadedRemoteProgress && ( - isTraktProgressActive || (hasLoadedWatchedItems && hasLoadedRemoteWatchedItems) + providerOwnsCompletedHistory || (hasLoadedWatchedItems && hasLoadedRemoteWatchedItems) ) internal fun cachedNextUpHasAired( @@ -1359,7 +1338,7 @@ private suspend fun resolveHomeNextUpCandidate( preferFurthestEpisode: Boolean, showUnairedNextUp: Boolean, dismissedNextUpKeys: Set, - isTraktProgressActive: Boolean, + providerOwnsCompletedHistory: Boolean, ): HomeNextUpResolutionAttempt { val contentId = completedEntry.content.id val meta = try { @@ -1375,17 +1354,16 @@ private suspend fun resolveHomeNextUpCandidate( return HomeNextUpResolutionAttempt.transientFailure() } - val resolvedProgressEntries = if (isTraktProgressActive) { - remapTraktProgressEntries(watchProgressEntries, contentId) - } else { - watchProgressEntries - } + val resolvedProgressEntries = WatchProgressRepository.prepareNextUpProgressEntries( + entries = watchProgressEntries, + contentId = contentId, + ) val resolvedWatchedItems = watchedItems val resolvedWatchedKeys = resolvedWatchedItems.mapTo(linkedSetOf()) { item -> watchedItemKey(item.type, item.id, item.season, item.episode) } - if (!isTraktProgressActive) { + if (!providerOwnsCompletedHistory) { WatchedRepository.reconcileFullyWatchedSeriesState( meta = meta, todayIsoDate = todayIsoDate, @@ -1461,17 +1439,6 @@ private fun MetaDetails.videoForSeriesAction(action: SeriesPrimaryAction): MetaV } } -private fun shouldUseAsTraktNextUpSeed( - entry: WatchProgressEntry, - nowEpochMs: Long, -): Boolean { - if (!entry.shouldUseAsCompletedSeedForContinueWatching()) return false - if (entry.source != WatchProgressSourceTraktPlayback) return true - - val ageMs = nowEpochMs - entry.lastUpdatedEpochMs - return ageMs in 0..OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS -} - private fun shouldTreatAsActiveInProgressForNextUpSuppression( progress: WatchProgressEntry, latestCompletedAt: Long?, @@ -1743,15 +1710,6 @@ internal fun buildHomeInProgressCacheSnapshot( } } -internal fun effectiveContinueWatchingCacheSource( - isTraktProgressActive: Boolean, -): WatchProgressSource = - if (isTraktProgressActive) { - WatchProgressSource.TRAKT - } else { - WatchProgressSource.NUVIO_SYNC - } - private fun CompletedSeriesCandidate.toContinueWatchingSeed(meta: com.nuvio.app.features.details.MetaDetails) = WatchProgressEntry( contentType = content.type, @@ -1921,37 +1879,3 @@ private fun ContinueWatchingItem.isCloudLibraryContinueWatchingItem(): Boolean = private fun WatchProgressEntry.isCloudLibraryProgressEntry(): Boolean = contentType.equals(CloudLibraryContentType, ignoreCase = true) || parentMetaType.equals(CloudLibraryContentType, ignoreCase = true) - -private suspend fun remapTraktProgressEntries( - entries: List, - contentId: String, -): List { - return entries.map { entry -> - if (entry.parentMetaId != contentId) { - entry - } else { - val mapping = TraktEpisodeMappingService.resolveAddonEpisodeMapping( - contentId = entry.parentMetaId, - contentType = entry.contentType ?: "series", - season = entry.seasonNumber, - episode = entry.episodeNumber, - episodeTitle = entry.episodeTitle, - ) - if (mapping != null) { - entry.copy( - seasonNumber = mapping.season, - episodeNumber = mapping.episode, - videoId = com.nuvio.app.features.watchprogress.buildPlaybackVideoId( - parentMetaId = entry.parentMetaId, - seasonNumber = mapping.season, - episodeNumber = mapping.episode, - fallbackVideoId = entry.videoId, - ), - episodeTitle = mapping.title ?: entry.episodeTitle, - ) - } else { - entry - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt index 6f59e155..bb206fff 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt @@ -4,6 +4,7 @@ import com.nuvio.app.features.library.LibraryItem import com.nuvio.app.features.library.LibrarySection import com.nuvio.app.features.watching.sync.WatchedSyncAdapter import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueWatching import kotlinx.coroutines.flow.Flow enum class TrackingLibraryTabKind { @@ -78,6 +79,19 @@ interface TrackingProgressProvider { val ownsCompletedHistoryProjection: Boolean get() = false + /** Optional age cutoff applied to continue-watching entries and completed next-up seeds. */ + fun continueWatchingCutoffEpochMs(daysCap: Int, nowEpochMs: Long): Long? = null + + /** Provider policy for deciding whether a progress row is a valid completed next-up seed. */ + fun shouldUseAsNextUpSeed(entry: WatchProgressEntry, nowEpochMs: Long): Boolean = + entry.shouldUseAsCompletedSeedForContinueWatching() + + /** Maps provider episode numbering into the application's metadata numbering when needed. */ + suspend fun prepareNextUpProgressEntries( + entries: List, + contentId: String, + ): List = entries + fun ensureLoaded() fun onProfileChanged() = Unit fun clearLocalState() = Unit diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingProgressProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingProgressProvider.kt index e1f98ea9..c0805b33 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingProgressProvider.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingProgressProvider.kt @@ -5,6 +5,9 @@ import com.nuvio.app.features.tracking.TrackingProgressProvider import com.nuvio.app.features.tracking.TrackingProgressSnapshot import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback +import com.nuvio.app.features.watchprogress.buildPlaybackVideoId +import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueWatching import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -17,6 +20,55 @@ object TraktTrackingProgressProvider : TrackingProgressProvider { override val providesCompleteMetadata: Boolean = true override val ownsCompletedHistoryProjection: Boolean = true + override fun continueWatchingCutoffEpochMs(daysCap: Int, nowEpochMs: Long): Long? { + val normalizedDaysCap = normalizeTraktContinueWatchingDaysCap(daysCap) + if (normalizedDaysCap == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) return null + return nowEpochMs - normalizedDaysCap.toLong() * MILLIS_PER_DAY + } + + override fun shouldUseAsNextUpSeed(entry: WatchProgressEntry, nowEpochMs: Long): Boolean { + if (!entry.shouldUseAsCompletedSeedForContinueWatching()) return false + if (entry.source != WatchProgressSourceTraktPlayback) return true + + val explicitPercent = entry.normalizedProgressPercent ?: return false + if (explicitPercent < TRAKT_PLAYBACK_NEXT_UP_SEED_PERCENT_THRESHOLD) return false + + val ageMs = nowEpochMs - entry.lastUpdatedEpochMs + return ageMs in 0..OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS + } + + override suspend fun prepareNextUpProgressEntries( + entries: List, + contentId: String, + ): List = entries.map { entry -> + if (entry.parentMetaId != contentId) { + entry + } else { + val mapping = TraktEpisodeMappingService.resolveAddonEpisodeMapping( + contentId = entry.parentMetaId, + contentType = entry.contentType, + season = entry.seasonNumber, + episode = entry.episodeNumber, + episodeTitle = entry.episodeTitle, + ) + if (mapping == null) { + entry + } else { + entry.copy( + seasonNumber = mapping.season, + episodeNumber = mapping.episode, + videoId = buildPlaybackVideoId( + parentMetaId = entry.parentMetaId, + seasonNumber = mapping.season, + episodeNumber = mapping.episode, + fallbackVideoId = entry.videoId, + ), + episodeTitle = mapping.title ?: entry.episodeTitle, + ) + } + } + } + override fun ensureLoaded() = TraktProgressRepository.ensureLoaded() override fun onProfileChanged() = TraktProgressRepository.onProfileChanged() @@ -88,3 +140,7 @@ object TraktTrackingProgressProvider : TrackingProgressProvider { override fun isHiddenFromProgress(contentId: String): Boolean = TraktProgressRepository.isShowHiddenFromProgress(contentId) } + +private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1_000L +private const val OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS = 3L * 60L * 1_000L +private const val TRAKT_PLAYBACK_NEXT_UP_SEED_PERCENT_THRESHOLD = 95f diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt index b4e89249..c5e7e0bd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt @@ -7,7 +7,6 @@ import com.nuvio.app.features.watching.domain.WatchingContentRef import kotlinx.serialization.Serializable internal const val WatchProgressCompletionPercentThreshold = 90f -internal const val WatchProgressTraktPlaybackNextUpSeedPercentThreshold = 95f internal const val WatchProgressSourceLocal = "local" internal const val WatchProgressSourceTraktPlayback = "trakt_playback" internal const val WatchProgressSourceTraktHistory = "trakt_history" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index 85c0b50c..37ab0534 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -1627,6 +1627,25 @@ object WatchProgressRepository { return activeProgressProvider()?.isHiddenFromProgress(contentId) == true } + fun activeProviderOwnsCompletedHistoryProjection(): Boolean = + activeProgressProvider()?.ownsCompletedHistoryProjection == true + + fun activeProviderContinueWatchingCutoffEpochMs( + daysCap: Int, + nowEpochMs: Long, + ): Long? = activeProgressProvider()?.continueWatchingCutoffEpochMs(daysCap, nowEpochMs) + + fun shouldUseAsNextUpSeed(entry: WatchProgressEntry, nowEpochMs: Long): Boolean = + activeProgressProvider()?.shouldUseAsNextUpSeed(entry, nowEpochMs) + ?: entry.shouldUseAsCompletedSeedForContinueWatching() + + suspend fun prepareNextUpProgressEntries( + entries: List, + contentId: String, + ): List = activeProgressProvider() + ?.prepareNextUpProgressEntries(entries, contentId) + ?: entries + private fun AddonsUiState.metadataProviderReadiness(): MetadataProviderReadiness { val enabled = addons.enabledAddons() val providers = enabled diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt index 2961bc44..c5bb0740 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt @@ -148,11 +148,7 @@ internal fun WatchProgressEntry.shouldTreatAsInProgressForContinueWatching(): Bo internal fun WatchProgressEntry.shouldUseAsCompletedSeedForContinueWatching(): Boolean { val entry = normalizedCompletion() if (isMalformedNextUpSeedContentId(entry.parentMetaId)) return false - if (!entry.isEffectivelyCompleted) return false - if (entry.source != WatchProgressSourceTraktPlayback) return true - - val explicitPercent = entry.normalizedProgressPercent ?: return false - return explicitPercent >= WatchProgressTraktPlaybackNextUpSeedPercentThreshold + return entry.isEffectivelyCompleted } internal fun shouldReplaceProgressSnapshotEntry( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt index 3323d3bf..2a70a4c1 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt @@ -18,8 +18,6 @@ import com.nuvio.app.features.watchprogress.parseReleaseDateToEpochMs import com.nuvio.app.features.watchprogress.resolvedProgressKey import com.nuvio.app.features.watchprogress.toContinueWatchingItem import com.nuvio.app.features.watched.WatchedItem -import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL -import com.nuvio.app.features.tracking.WatchProgressSource import com.nuvio.app.features.watching.domain.WatchingContentRef import kotlinx.serialization.json.Json import kotlin.test.Test @@ -30,18 +28,6 @@ import kotlin.test.assertTrue class HomeScreenTest { - @Test - fun `continue watching cache uses the effective progress source`() { - assertEquals( - WatchProgressSource.TRAKT, - effectiveContinueWatchingCacheSource(isTraktProgressActive = true), - ) - assertEquals( - WatchProgressSource.NUVIO_SYNC, - effectiveContinueWatchingCacheSource(isTraktProgressActive = false), - ) - } - @Test fun `home trakt continue watching candidate limits match TV`() { assertEquals(300, HomeContinueWatchingMaxRecentProgressItems) @@ -435,7 +421,7 @@ class HomeScreenTest { } @Test - fun `Trakt continue watching window filters old progress only when Trakt source is active`() { + fun `provider continue watching cutoff filters old progress`() { val oldEntry = progressEntry( videoId = "old", title = "Old", @@ -452,25 +438,21 @@ class HomeScreenTest { ) val entries = listOf(oldEntry, recentEntry) - val filtered = filterEntriesForTraktContinueWatchingWindow( + val filtered = filterEntriesForContinueWatchingWindow( entries = entries, - isTraktProgressActive = true, - daysCap = 60, - nowEpochMs = 90L * MILLIS_PER_DAY, + cutoffEpochMs = 30L * MILLIS_PER_DAY, ) - val nuvioSource = filterEntriesForTraktContinueWatchingWindow( + val sourceWithoutCutoff = filterEntriesForContinueWatchingWindow( entries = entries, - isTraktProgressActive = false, - daysCap = 60, - nowEpochMs = 90L * MILLIS_PER_DAY, + cutoffEpochMs = null, ) assertEquals(listOf("recent"), filtered.map(WatchProgressEntry::videoId)) - assertEquals(listOf("old", "recent"), nuvioSource.map(WatchProgressEntry::videoId)) + assertEquals(listOf("old", "recent"), sourceWithoutCutoff.map(WatchProgressEntry::videoId)) } @Test - fun `Trakt all history window keeps old progress`() { + fun `provider without a continue watching cutoff keeps old progress`() { val oldEntry = progressEntry( videoId = "old", title = "Old", @@ -486,11 +468,9 @@ class HomeScreenTest { episodeNumber = null, ) - val result = filterEntriesForTraktContinueWatchingWindow( + val result = filterEntriesForContinueWatchingWindow( entries = listOf(oldEntry, recentEntry), - isTraktProgressActive = true, - daysCap = TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL, - nowEpochMs = 90L * MILLIS_PER_DAY, + cutoffEpochMs = null, ) assertEquals(listOf("old", "recent"), result.map(WatchProgressEntry::videoId)) @@ -516,7 +496,7 @@ class HomeScreenTest { val result = buildHomeNextUpSeedCandidates( progressEntries = listOf(completedProgress), watchedItems = listOf(olderWatchedItem), - isTraktProgressActive = false, + providerOwnsCompletedHistory = false, preferFurthestEpisode = true, nowEpochMs = 3_000L, ) @@ -547,7 +527,7 @@ class HomeScreenTest { val result = buildHomeNextUpSeedCandidates( progressEntries = listOf(olderCompletedProgress), watchedItems = listOf(newerWatchedItem), - isTraktProgressActive = false, + providerOwnsCompletedHistory = false, preferFurthestEpisode = true, nowEpochMs = 3_000L, ) @@ -557,7 +537,7 @@ class HomeScreenTest { } @Test - fun `Trakt next up seeds ignore watched items from the separate watched sync`() { + fun `provider-owned completed history ignores the separate watched projection`() { val traktProgress = progressEntry( videoId = "show:1:2", title = "Show", @@ -576,7 +556,7 @@ class HomeScreenTest { val result = buildHomeNextUpSeedCandidates( progressEntries = listOf(traktProgress), watchedItems = listOf(watchedItem), - isTraktProgressActive = true, + providerOwnsCompletedHistory = true, preferFurthestEpisode = true, nowEpochMs = 4_000L, ) @@ -607,7 +587,7 @@ class HomeScreenTest { fun `home next up waits for the selected seed source before resolving or clearing cache`() { assertFalse( isHomeNextUpSeedSourceLoaded( - isTraktProgressActive = false, + providerOwnsCompletedHistory = false, hasLoadedRemoteProgress = false, hasLoadedWatchedItems = true, hasLoadedRemoteWatchedItems = true, @@ -615,7 +595,7 @@ class HomeScreenTest { ) assertFalse( isHomeNextUpSeedSourceLoaded( - isTraktProgressActive = false, + providerOwnsCompletedHistory = false, hasLoadedRemoteProgress = true, hasLoadedWatchedItems = false, hasLoadedRemoteWatchedItems = true, @@ -623,7 +603,7 @@ class HomeScreenTest { ) assertFalse( isHomeNextUpSeedSourceLoaded( - isTraktProgressActive = false, + providerOwnsCompletedHistory = false, hasLoadedRemoteProgress = true, hasLoadedWatchedItems = true, hasLoadedRemoteWatchedItems = false, @@ -631,7 +611,7 @@ class HomeScreenTest { ) assertTrue( isHomeNextUpSeedSourceLoaded( - isTraktProgressActive = false, + providerOwnsCompletedHistory = false, hasLoadedRemoteProgress = true, hasLoadedWatchedItems = true, hasLoadedRemoteWatchedItems = true, @@ -639,7 +619,7 @@ class HomeScreenTest { ) assertTrue( isHomeNextUpSeedSourceLoaded( - isTraktProgressActive = true, + providerOwnsCompletedHistory = true, hasLoadedRemoteProgress = true, hasLoadedWatchedItems = false, hasLoadedRemoteWatchedItems = false, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktTrackingProgressProviderTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktTrackingProgressProviderTest.kt new file mode 100644 index 00000000..666d0e67 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktTrackingProgressProviderTest.kt @@ -0,0 +1,68 @@ +package com.nuvio.app.features.trakt + +import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TraktTrackingProgressProviderTest { + + @Test + fun `continue watching cutoff is provider policy`() { + val now = 100L * MILLIS_PER_DAY + + assertEquals( + 40L * MILLIS_PER_DAY, + TraktTrackingProgressProvider.continueWatchingCutoffEpochMs(daysCap = 60, nowEpochMs = now), + ) + assertNull( + TraktTrackingProgressProvider.continueWatchingCutoffEpochMs( + daysCap = TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL, + nowEpochMs = now, + ), + ) + } + + @Test + fun `optimistic playback next up seed obeys provider threshold and freshness`() { + val now = 1_000_000L + val recent = entry(progressPercent = 95f, lastUpdatedEpochMs = now - 1_000L) + + assertTrue(TraktTrackingProgressProvider.shouldUseAsNextUpSeed(recent, now)) + assertFalse( + TraktTrackingProgressProvider.shouldUseAsNextUpSeed( + recent.copy(progressPercent = 94f), + now, + ), + ) + assertFalse( + TraktTrackingProgressProvider.shouldUseAsNextUpSeed( + recent.copy(lastUpdatedEpochMs = now - 4L * 60L * 1_000L), + now, + ), + ) + } + + private fun entry(progressPercent: Float, lastUpdatedEpochMs: Long): WatchProgressEntry = + WatchProgressEntry( + contentType = "series", + parentMetaId = "show", + parentMetaType = "series", + videoId = "show:1:4", + title = "Show", + seasonNumber = 1, + episodeNumber = 4, + lastPositionMs = 940L, + durationMs = 1_000L, + lastUpdatedEpochMs = lastUpdatedEpochMs, + progressPercent = progressPercent, + source = WatchProgressSourceTraktPlayback, + ) + + private companion object { + const val MILLIS_PER_DAY = 24L * 60L * 60L * 1_000L + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt index 85d22929..d53ce360 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt @@ -10,7 +10,7 @@ import kotlin.test.assertTrue class WatchingStateTest { @Test - fun `latest completed ignores Trakt playback below next up seed threshold`() { + fun `latest completed aggregates provider-filtered completed progress`() { val almostCompletePlayback = entry( videoId = "show:1:4", seasonNumber = 1, @@ -24,7 +24,7 @@ class WatchingStateTest { watchedItems = emptyList(), ) - assertTrue(result.isEmpty()) + assertEquals(4, result.values.single().episodeNumber) } @Test diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt index 02bd0e6a..dff8253b 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt @@ -300,22 +300,6 @@ class WatchProgressRulesTest { assertEquals(listOf("active-show_s1e1"), result.map { it.resolvedProgressKey() }) } - @Test - fun `Trakt playback next up seeds require TV percent threshold`() { - val belowSeedThreshold = entry( - videoId = "show:1:4", - parentMetaId = "show", - seasonNumber = 1, - episodeNumber = 4, - progressPercent = 94f, - source = WatchProgressSourceTraktPlayback, - ) - val seed = belowSeedThreshold.copy(progressPercent = 95f) - - assertFalse(belowSeedThreshold.shouldUseAsCompletedSeedForContinueWatching()) - assertTrue(seed.shouldUseAsCompletedSeedForContinueWatching()) - } - @Test fun `Trakt history is not treated as active resume`() { val history = entry( From c1022e2eb5c41fe7692064f026cd3a5bb7c546e0 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:04:19 +0530 Subject: [PATCH 15/59] fix(simkl): enforce compliant sync cadence --- .../commonMain/kotlin/com/nuvio/app/App.kt | 2 +- .../com/nuvio/app/core/sync/SyncManager.kt | 5 + .../app/features/library/LibraryRepository.kt | 27 +++- .../simkl/SimklApplicationAdapters.kt | 26 ++-- .../app/features/simkl/SimklAuthRepository.kt | 3 +- .../features/simkl/SimklMutationRepository.kt | 5 +- .../app/features/simkl/SimklRefreshPolicy.kt | 58 ++++++++ .../app/features/simkl/SimklSyncRepository.kt | 92 ++++++------ .../app/features/tracking/TrackingReads.kt | 14 +- .../trakt/TraktTrackingLibraryProvider.kt | 8 +- .../features/simkl/SimklRefreshPolicyTest.kt | 138 ++++++++++++++++++ 11 files changed, 315 insertions(+), 63 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicy.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 38f74df3..26b0abc3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -1219,7 +1219,7 @@ private fun MainAppContent( val activeProfileId = profileState.activeProfile?.profileIndex ?: return@LaunchedEffect SyncManager.pullAllForProfile(activeProfileId) AppForegroundMonitor.events().collect { - SyncManager.requestForegroundPull(activeProfileId, force = true) + SyncManager.requestForegroundPull(activeProfileId) } } var resumePromptItem by remember { mutableStateOf(null) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt index 87727fc2..76d1d63f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt @@ -346,6 +346,11 @@ object SyncManager { } } + synchronized(pullStateLock) { + lastFullPullAtMs = EpisodeReleaseDatePlatform.nowEpochMs() + lastFullPullProfileId = profileId + } + log.i { "Foreground sync completed profile=$profileId" } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt index 88e95a2e..83851b31 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt @@ -13,6 +13,7 @@ import com.nuvio.app.features.tracking.TrackingLibraryProvider import com.nuvio.app.features.tracking.TrackingLibraryTab import com.nuvio.app.features.tracking.TrackingLibraryTabKind import com.nuvio.app.features.tracking.TrackingProviderRegistry +import com.nuvio.app.features.tracking.TrackingRefreshIntent import com.nuvio.app.features.tracking.TrackingSettingsRepository import com.nuvio.app.features.tracking.effectiveLibrarySourceMode as resolveEffectiveLibrarySourceMode import com.nuvio.app.features.tracking.providerId @@ -97,7 +98,11 @@ object LibraryRepository { TrackingProviderRegistry.connectedProviderIds.collectLatest { TrackingProviderRegistry.connectedLibraryProviders().forEach(TrackingLibraryProvider::prepare) activeLibraryProvider()?.let { provider -> - refreshLibraryProvider(provider, "authentication change") + refreshLibraryProvider( + provider = provider, + reason = "authentication change", + intent = TrackingRefreshIntent.INVALIDATED, + ) } publish() } @@ -206,14 +211,21 @@ object LibraryRepository { ) != null } - suspend fun pullFromServer(profileId: Int) { + suspend fun pullFromServer( + profileId: Int, + refreshIntent: TrackingRefreshIntent = TrackingRefreshIntent.AUTOMATIC, + ) { val operationToken = activeOperationToken(profileId) ?: run { log.d { "Skipping library pull for inactive profile $profileId" } return } activeLibraryProvider()?.let { provider -> - refreshLibraryProvider(provider, "explicit pull") + refreshLibraryProvider( + provider = provider, + reason = "explicit pull", + intent = refreshIntent, + ) if (!isActiveOperation(operationToken)) return publish() return @@ -566,7 +578,11 @@ object LibraryRepository { private fun refreshLibraryProviderAsync(provider: TrackingLibraryProvider) { syncScope.launch { - refreshLibraryProvider(provider, "background refresh") + refreshLibraryProvider( + provider = provider, + reason = "background refresh", + intent = TrackingRefreshIntent.AUTOMATIC, + ) publish() } } @@ -574,9 +590,10 @@ object LibraryRepository { private suspend fun refreshLibraryProvider( provider: TrackingLibraryProvider, reason: String, + intent: TrackingRefreshIntent, ) { try { - provider.refresh() + provider.refresh(intent) } catch (error: CancellationException) { throw error } catch (error: Throwable) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt index 1a4ab121..8eedfa34 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt @@ -13,6 +13,7 @@ import com.nuvio.app.features.tracking.TrackingListStatus import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.tracking.TrackingProgressProvider import com.nuvio.app.features.tracking.TrackingProgressSnapshot +import com.nuvio.app.features.tracking.TrackingRefreshIntent import com.nuvio.app.features.tracking.TrackingWatchedProvider import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watchprogress.WatchProgressEntry @@ -52,8 +53,8 @@ object SimklLibraryRepository { publish(SimklSyncRepository.state.value) } - suspend fun refreshNow() { - SimklSyncRepository.refreshNow() + suspend fun refresh(intent: TrackingRefreshIntent) { + SimklSyncRepository.refresh(intent) publish(SimklSyncRepository.state.value) } @@ -93,7 +94,7 @@ object SimklLibraryRepository { check(result.isComplete) { "Simkl could not match ${result.notFoundCount} of ${result.attemptedCount} library items" } - refreshNow() + refresh(TrackingRefreshIntent.INVALIDATED) } private fun publish(syncState: SimklSyncUiState) { @@ -114,7 +115,8 @@ object SimklTrackingLibraryProvider : TrackingLibraryProvider { override fun onProfileChanged() = SimklLibraryRepository.ensureLoaded() - override suspend fun refresh() = SimklLibraryRepository.refreshNow() + override suspend fun refresh(intent: TrackingRefreshIntent) = + SimklLibraryRepository.refresh(intent) override fun snapshot(): TrackingLibrarySnapshot { val state = SimklLibraryRepository.uiState.value @@ -179,13 +181,13 @@ object SimklWatchedSyncAdapter : TrackingWatchedProvider { override val providerId: TrackingProviderId = TrackingProviderId.SIMKL override suspend fun pull(profileId: Int, pageSize: Int): List { if (profileId != ProfileRepository.activeProfileId) return emptyList() - SimklSyncRepository.ensureFresh() + SimklSyncRepository.refresh(TrackingRefreshIntent.AUTOMATIC) return SimklSyncRepository.state.value.snapshot.toSimklWatchedProjection().items } override suspend fun pullFullyWatchedSeriesKeys(profileId: Int): Set? { if (profileId != ProfileRepository.activeProfileId) return null - SimklSyncRepository.ensureFresh() + SimklSyncRepository.refresh(TrackingRefreshIntent.AUTOMATIC) return SimklSyncRepository.state.value.snapshot.toSimklWatchedProjection().fullyWatchedSeriesKeys } @@ -258,8 +260,8 @@ object SimklProgressRepository { publish(SimklSyncRepository.state.value) } - suspend fun refreshNow() { - SimklSyncRepository.refreshNow() + suspend fun refresh(intent: TrackingRefreshIntent) { + SimklSyncRepository.refresh(intent) publish(SimklSyncRepository.state.value) } @@ -290,7 +292,9 @@ object SimklProgressRepository { } } SimklSyncRepository.commitPlaybackRemoval(removed) - if (removed.isNotEmpty()) SimklSyncRepository.refreshAsync() + if (removed.isNotEmpty()) { + SimklSyncRepository.refreshAsync(TrackingRefreshIntent.INVALIDATED) + } } private fun publish(syncState: SimklSyncUiState) { @@ -312,7 +316,9 @@ object SimklTrackingProgressProvider : TrackingProgressProvider { override fun onProfileChanged() = SimklProgressRepository.ensureLoaded() override suspend fun refresh(force: Boolean, sourceChanged: Boolean) = - SimklProgressRepository.refreshNow() + SimklProgressRepository.refresh( + if (sourceChanged) TrackingRefreshIntent.INVALIDATED else TrackingRefreshIntent.AUTOMATIC, + ) override fun snapshot(): TrackingProgressSnapshot { val state = SimklProgressRepository.uiState.value 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 6a373b8f..2d0b04dc 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 @@ -7,6 +7,7 @@ import com.nuvio.app.features.tracking.TrackingCapability import com.nuvio.app.features.tracking.TrackingProviderDescriptor import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.tracking.TrackingProviderRegistry +import com.nuvio.app.features.tracking.TrackingRefreshIntent import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -262,7 +263,7 @@ object SimklAuthRepository : TrackingAuthProvider { persistMetadata() publish(isLoading = false, error = null) fetchAndStoreUserSettings() - SimklSyncRepository.refreshAsync() + SimklSyncRepository.refreshAsync(TrackingRefreshIntent.INVALIDATED) } private suspend fun fetchAndStoreUserSettings(): String? { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt index 2f97cabc..ed73aaee 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt @@ -12,6 +12,7 @@ import com.nuvio.app.features.tracking.TrackingMediaReference import com.nuvio.app.features.tracking.TrackingMutationResult import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.tracking.TrackingProviderRegistry +import com.nuvio.app.features.tracking.TrackingRefreshIntent import com.nuvio.app.features.tracking.TrackingScrobbleAction import com.nuvio.app.features.tracking.TrackingScrobbleEvent import com.nuvio.app.features.tracking.TrackingScrobbler @@ -118,7 +119,9 @@ object SimklMutationRepository : TrackingListWriter, TrackingHistoryWriter, Trac private val service by lazy { SimklMutationService( client = SimklApi.client, - onMutationCommitted = SimklSyncRepository::refreshAsync, + onMutationCommitted = { + SimklSyncRepository.refreshAsync(TrackingRefreshIntent.INVALIDATED) + }, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicy.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicy.kt new file mode 100644 index 00000000..c5ab7fe1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicy.kt @@ -0,0 +1,58 @@ +package com.nuvio.app.features.simkl + +import com.nuvio.app.features.tracking.TrackingRefreshIntent +import kotlinx.atomicfu.atomic +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +internal const val SIMKL_AUTOMATIC_REFRESH_INTERVAL_MS = 15L * 60L * 1_000L + +internal fun shouldRunSimklRefresh( + intent: TrackingRefreshIntent, + lastCheckedAtEpochMs: Long?, + nowEpochMs: Long, + hasError: Boolean, + automaticIntervalMs: Long = SIMKL_AUTOMATIC_REFRESH_INTERVAL_MS, +): Boolean { + if (intent != TrackingRefreshIntent.AUTOMATIC) return true + if (hasError || lastCheckedAtEpochMs == null) return true + + val elapsedMs = nowEpochMs - lastCheckedAtEpochMs + return elapsedMs < 0L || elapsedMs >= automaticIntervalMs +} + +/** + * Serializes provider refreshes and coalesces callers that overlap for the same profile generation. + * + * Library, watched, and progress are projections of one Simkl snapshot. They may request the same + * refresh concurrently, but only the first request should reach the network. + */ +internal class SimklRefreshGate { + private val mutex = Mutex() + private val completionSequence = atomic(0L) + private var lastCompletedProfileGeneration: Long? = null + + suspend fun runIfNeeded( + profileGeneration: Long, + shouldRun: () -> Boolean, + block: suspend () -> Unit, + ) { + val observedSequence = completionSequence.value + mutex.withLock { + if ( + completionSequence.value != observedSequence && + lastCompletedProfileGeneration == profileGeneration + ) { + return + } + if (!shouldRun()) return + + try { + block() + } finally { + lastCompletedProfileGeneration = profileGeneration + completionSequence.incrementAndGet() + } + } + } +} 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 index 184dc1c6..78b0cea6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt @@ -5,6 +5,7 @@ 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 com.nuvio.app.features.tracking.TrackingRefreshIntent import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -13,8 +14,6 @@ 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 @@ -29,7 +28,7 @@ object SimklSyncRepository : TrackingProfileStore { explicitNulls = false } private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private val refreshMutex = Mutex() + private val refreshGate = SimklRefreshGate() private val engine = SimklSyncEngine( remote = SimklApiSyncRemote(), nowEpochMs = SimklPlatformClock::nowEpochMs, @@ -60,51 +59,60 @@ object SimklSyncRepository : TrackingProfileStore { _state.value = SimklSyncUiState(snapshot = snapshot, hasLoaded = true) } - fun refreshAsync() { - scope.launch { refreshNow() } + fun refreshAsync(intent: TrackingRefreshIntent) { + scope.launch { refresh(intent) } } - suspend fun ensureFresh() { + suspend fun refresh(intent: TrackingRefreshIntent) { 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", + val requestedGeneration = profileGeneration + refreshGate.runIfNeeded( + profileGeneration = requestedGeneration, + shouldRun = { + val current = _state.value + requestedGeneration == profileGeneration && + SimklAuthRepository.isAuthenticated.value && + shouldRunSimklRefresh( + intent = intent, + lastCheckedAtEpochMs = current.snapshot.lastCheckedAtEpochMs, + nowEpochMs = SimklPlatformClock.nowEpochMs(), + hasError = current.errorMessage != null, ) - } - return - } - - if (generation != profileGeneration || profileId != ProfileRepository.activeProfileId) return - SimklSyncStorage.savePayload(json.encodeToString(result)) - _state.value = SimklSyncUiState( - snapshot = result, - hasLoaded = true, - ) + }, + ) { + refreshSnapshot(requestedGeneration) } } + private suspend fun refreshSnapshot(generation: Long) { + val profileId = ProfileRepository.activeProfileId + 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, + ) + } + internal fun commitPlaybackRemoval(sessionIds: Set) { if (sessionIds.isEmpty()) return ensureLoaded() @@ -133,6 +141,4 @@ object SimklSyncRepository : TrackingProfileStore { 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/tracking/TrackingReads.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt index bb206fff..5ee808b2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt @@ -28,6 +28,18 @@ data class TrackingLibrarySnapshot( val errorMessage: String? = null, ) +/** Why a provider refresh was requested. Providers own the matching cache policy. */ +enum class TrackingRefreshIntent { + /** Lifecycle, startup, reconnect, or other application-driven refresh. */ + AUTOMATIC, + + /** An explicit user action, such as Sync now or pull-to-refresh. */ + USER_INITIATED, + + /** A successful write or authentication change made the local snapshot stale. */ + INVALIDATED, +} + /** * Provider-owned projection of remote library state into application models. * @@ -42,7 +54,7 @@ interface TrackingLibraryProvider { fun prepare() = Unit fun onProfileChanged() = Unit fun clearLocalState() = Unit - suspend fun refresh() + suspend fun refresh(intent: TrackingRefreshIntent) fun snapshot(): TrackingLibrarySnapshot fun contains(contentId: String, contentType: String? = null): Boolean fun find(contentId: String): LibraryItem? diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt index ecca9391..a8fe68c8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt @@ -7,6 +7,7 @@ import com.nuvio.app.features.tracking.TrackingLibrarySnapshot import com.nuvio.app.features.tracking.TrackingLibraryTab import com.nuvio.app.features.tracking.TrackingLibraryTabKind import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.TrackingRefreshIntent import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -22,7 +23,12 @@ object TraktTrackingLibraryProvider : TrackingLibraryProvider { override fun clearLocalState() = TraktLibraryRepository.clearLocalState() - override suspend fun refresh() = TraktLibraryRepository.refreshNow() + override suspend fun refresh(intent: TrackingRefreshIntent) = when (intent) { + TrackingRefreshIntent.AUTOMATIC -> TraktLibraryRepository.ensureFresh() + TrackingRefreshIntent.USER_INITIATED, + TrackingRefreshIntent.INVALIDATED, + -> TraktLibraryRepository.refreshNow() + } override fun snapshot(): TrackingLibrarySnapshot { val state = TraktLibraryRepository.uiState.value diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt new file mode 100644 index 00000000..9a5455b9 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt @@ -0,0 +1,138 @@ +package com.nuvio.app.features.simkl + +import com.nuvio.app.features.tracking.TrackingRefreshIntent +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SimklRefreshPolicyTest { + @Test + fun `automatic refresh is throttled for fifteen minutes`() { + val lastCheckedAt = 1_000L + + assertFalse( + shouldRunSimklRefresh( + intent = TrackingRefreshIntent.AUTOMATIC, + lastCheckedAtEpochMs = lastCheckedAt, + nowEpochMs = lastCheckedAt + SIMKL_AUTOMATIC_REFRESH_INTERVAL_MS - 1L, + hasError = false, + ), + ) + assertTrue( + shouldRunSimklRefresh( + intent = TrackingRefreshIntent.AUTOMATIC, + lastCheckedAtEpochMs = lastCheckedAt, + nowEpochMs = lastCheckedAt + SIMKL_AUTOMATIC_REFRESH_INTERVAL_MS, + hasError = false, + ), + ) + } + + @Test + fun `missing stale or failed snapshots refresh automatically`() { + assertTrue( + shouldRunSimklRefresh( + intent = TrackingRefreshIntent.AUTOMATIC, + lastCheckedAtEpochMs = null, + nowEpochMs = 1_000L, + hasError = false, + ), + ) + assertTrue( + shouldRunSimklRefresh( + intent = TrackingRefreshIntent.AUTOMATIC, + lastCheckedAtEpochMs = 1_000L, + nowEpochMs = 1_001L, + hasError = true, + ), + ) + assertTrue( + shouldRunSimklRefresh( + intent = TrackingRefreshIntent.AUTOMATIC, + lastCheckedAtEpochMs = 2_000L, + nowEpochMs = 1_000L, + hasError = false, + ), + ) + } + + @Test + fun `manual and invalidated refreshes bypass automatic freshness`() { + TrackingRefreshIntent.entries + .filterNot { intent -> intent == TrackingRefreshIntent.AUTOMATIC } + .forEach { intent -> + assertTrue( + shouldRunSimklRefresh( + intent = intent, + lastCheckedAtEpochMs = 1_000L, + nowEpochMs = 1_001L, + hasError = false, + ), + ) + } + } + + @Test + fun `overlapping refreshes for one profile generation are coalesced`() = runBlocking { + val gate = SimklRefreshGate() + val firstEntered = CompletableDeferred() + val releaseFirst = CompletableDeferred() + val secondStarted = CompletableDeferred() + var executions = 0 + + val first = async { + gate.runIfNeeded(profileGeneration = 7L, shouldRun = { true }) { + executions += 1 + firstEntered.complete(Unit) + releaseFirst.await() + } + } + firstEntered.await() + val second = async { + secondStarted.complete(Unit) + gate.runIfNeeded(profileGeneration = 7L, shouldRun = { true }) { + executions += 1 + } + } + secondStarted.await() + releaseFirst.complete(Unit) + + first.await() + second.await() + assertEquals(1, executions) + } + + @Test + fun `a new profile generation is not coalesced with an old profile refresh`() = runBlocking { + val gate = SimklRefreshGate() + val firstEntered = CompletableDeferred() + val releaseFirst = CompletableDeferred() + val secondStarted = CompletableDeferred() + val executedGenerations = mutableListOf() + + val first = async { + gate.runIfNeeded(profileGeneration = 1L, shouldRun = { true }) { + executedGenerations += 1L + firstEntered.complete(Unit) + releaseFirst.await() + } + } + firstEntered.await() + val second = async { + secondStarted.complete(Unit) + gate.runIfNeeded(profileGeneration = 2L, shouldRun = { true }) { + executedGenerations += 2L + } + } + secondStarted.await() + releaseFirst.complete(Unit) + + first.await() + second.await() + assertEquals(listOf(1L, 2L), executedGenerations) + } +} From adcc829a1aa1d98f9d53f86f5107cae0a3d4c30b Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:08:43 +0530 Subject: [PATCH 16/59] fix(simkl): centralize oauth request policy --- .../app/features/simkl/SimklApiClient.kt | 18 +++++-- .../app/features/simkl/SimklAuthRepository.kt | 21 ++++---- .../app/features/simkl/SimklApiClientTest.kt | 48 +++++++++++++++++++ 3 files changed, 70 insertions(+), 17 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt index 47ed55aa..cac9481a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt @@ -20,12 +20,18 @@ internal enum class SimklHttpMethod { DELETE, } +internal enum class SimklRetryPolicy { + TRANSIENT_FAILURES, + NEVER, +} + internal data class SimklApiRequest( val method: SimklHttpMethod, val path: String, val query: Map = emptyMap(), val body: String = "", val requiresAuthentication: Boolean = true, + val retryPolicy: SimklRetryPolicy = SimklRetryPolicy.TRANSIENT_FAILURES, val scrobbleStopConflictIsSuccess: Boolean = false, ) @@ -78,7 +84,11 @@ internal class SimklApiClient( } var lastTransportFailure: Throwable? = null - for (attempt in 0..MAX_RETRIES) { + val maxRetries = when (request.retryPolicy) { + SimklRetryPolicy.TRANSIENT_FAILURES -> MAX_RETRIES + SimklRetryPolicy.NEVER -> 0 + } + for (attempt in 0..maxRetries) { awaitRateLimit(request.method) val response = try { engine.execute( @@ -94,7 +104,7 @@ internal class SimklApiClient( throw error } catch (error: Throwable) { lastTransportFailure = error - if (attempt == MAX_RETRIES) { + if (attempt == maxRetries) { throw SimklApiException( status = null, errorCode = "transport_failure", @@ -112,12 +122,12 @@ internal class SimklApiClient( return@withLock response.toApiResponse(isSoftSuccess = true) } SimklResponseAction.REAUTHENTICATE -> { - onUnauthorized() + if (request.requiresAuthentication) onUnauthorized() throw response.toApiException(json) } SimklResponseAction.FAIL -> throw response.toApiException(json) SimklResponseAction.RETRY -> { - if (attempt == MAX_RETRIES) throw response.toApiException(json) + if (attempt == maxRetries) throw response.toApiException(json) sleep( retryDelayMs( attempt = attempt, 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 2d0b04dc..c7da5709 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 @@ -1,7 +1,6 @@ package com.nuvio.app.features.simkl import co.touchlab.kermit.Logger -import com.nuvio.app.features.addons.httpRequestRaw import com.nuvio.app.features.tracking.TrackingAuthProvider import com.nuvio.app.features.tracking.TrackingCapability import com.nuvio.app.features.tracking.TrackingProviderDescriptor @@ -220,11 +219,14 @@ object SimklAuthRepository : TrackingAuthProvider { redirectUri = SimklConfig.REDIRECT_URI, ) val response = try { - httpRequestRaw( - method = "POST", - url = "$SIMKL_API_BASE_URL/oauth/token", - headers = simklRequestHeaders(contentTypeJson = true), - body = json.encodeToString(request), + SimklApi.client.execute( + SimklApiRequest( + method = SimklHttpMethod.POST, + path = "/oauth/token", + body = json.encodeToString(request), + requiresAuthentication = false, + retryPolicy = SimklRetryPolicy.NEVER, + ), ) } catch (error: CancellationException) { throw error @@ -235,13 +237,6 @@ object SimklAuthRepository : TrackingAuthProvider { publish(isLoading = false, error = SimklAuthError.TOKEN_EXCHANGE_FAILED) return@withLock } - - if (response.status !in 200..299) { - clearPendingAuthorization() - persistMetadata() - publish(isLoading = false, error = SimklAuthError.TOKEN_EXCHANGE_FAILED) - return@withLock - } val token = runCatching { json.decodeFromString(response.body) } .getOrNull() ?.takeIf { it.accessToken.isNotBlank() } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt index bf5edc94..8716bfe6 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt @@ -68,6 +68,54 @@ class SimklApiClientTest { assertTrue(request.headers.getValue("User-Agent").contains('/')) } + @Test + fun `single use unauthenticated posts keep metadata and never retry`() = runBlocking { + val engine = RecordingEngine(response(503), response(200)) + val harness = TestHarness(engine) + + assertFailsWith { + harness.client.execute( + SimklApiRequest( + method = SimklHttpMethod.POST, + path = "/oauth/token", + body = "{}", + requiresAuthentication = false, + retryPolicy = SimklRetryPolicy.NEVER, + ), + ) + } + + val request = engine.requests.single() + assertTrue("client_id=" in request.url) + assertTrue("app-name=" in request.url) + assertTrue("app-version=" in request.url) + assertTrue(request.headers.getValue("User-Agent").contains('/')) + assertFalse("Authorization" in request.headers) + assertTrue(harness.sleeps.isEmpty()) + assertFalse(harness.wasUnauthorized) + } + + @Test + fun `unauthenticated 401 does not invalidate an existing session`() = runBlocking { + val engine = RecordingEngine(response(401)) + val harness = TestHarness(engine) + + assertFailsWith { + harness.client.execute( + SimklApiRequest( + method = SimklHttpMethod.POST, + path = "/oauth/token", + body = "{}", + requiresAuthentication = false, + retryPolicy = SimklRetryPolicy.NEVER, + ), + ) + } + + assertFalse(harness.wasUnauthorized) + assertEquals(1, engine.requests.size) + } + @Test fun `authenticated requests are serialized at documented method rates`() = runBlocking { val engine = RecordingEngine(response(200), response(200), response(200), response(200)) From 32449a7a15e8eda3040ba83095e3ce070912a6b2 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:12:42 +0530 Subject: [PATCH 17/59] feat(tracking): expose manual provider refresh --- .../composeResources/values/strings.xml | 2 + .../app/features/library/LibraryScreen.kt | 18 ++++++++- .../features/settings/TrackingSettingsPage.kt | 40 ++++++++++++++++++- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index a4501dc0..1083f44c 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1138,6 +1138,7 @@ Simkl did not accept the sign-in callback. Please try again. The Simkl sign-in request expired. Start again. Could not complete Simkl sign in. Please try again. + Sync now Simkl authorization expired or was revoked. Connect again. Simkl Simkl watchlist selected @@ -1651,6 +1652,7 @@ Select type Show horizontal shelves Show vertical grid + Refresh library Couldn't load library Other Oldest added diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt index 434f3c9a..51bcf130 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt @@ -84,6 +84,7 @@ import com.nuvio.app.features.home.components.HomePosterCard import com.nuvio.app.features.home.components.HomeSkeletonRow import com.nuvio.app.features.home.components.posterGridColumnCountForWidth import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.tracking.TrackingRefreshIntent import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watching.application.WatchingState import kotlinx.coroutines.flow.Flow @@ -170,7 +171,10 @@ fun LibraryScreen( val retryLibraryLoad: () -> Unit = { NetworkStatusRepository.requestRefresh(force = true) coroutineScope.launch { - LibraryRepository.pullFromServer(ProfileRepository.activeProfileId) + LibraryRepository.pullFromServer( + profileId = ProfileRepository.activeProfileId, + refreshIntent = TrackingRefreshIntent.USER_INITIATED, + ) } } @@ -256,6 +260,18 @@ fun LibraryScreen( modifier = Modifier.padding(horizontal = 16.dp), actions = { if (sourceMode == LibraryViewMode.Saved) { + if (isRemoteSource) { + IconButton( + onClick = retryLibraryLoad, + enabled = !uiState.isLoading, + ) { + Icon( + imageVector = Icons.Rounded.Refresh, + contentDescription = stringResource(Res.string.library_refresh), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } val targetLayout = if (displaySettings.layoutMode == LibraryLayoutMode.HORIZONTAL) { LibraryLayoutMode.VERTICAL } else { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingSettingsPage.kt index 038366da..87f38604 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingSettingsPage.kt @@ -28,6 +28,7 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -38,18 +39,21 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.features.library.LibrarySourceMode import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.simkl.SimklAuthError import com.nuvio.app.features.simkl.SimklAuthRepository import com.nuvio.app.features.simkl.SimklAuthUiState import com.nuvio.app.features.simkl.SimklConnectionMode +import com.nuvio.app.features.simkl.SimklSyncRepository import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktAuthUiState import com.nuvio.app.features.trakt.TraktConnectionMode import com.nuvio.app.features.trakt.TraktContinueWatchingDaysOptions import com.nuvio.app.features.trakt.MoreLikeThisSourcePreference import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.TrackingRefreshIntent import com.nuvio.app.features.tracking.TrackingSettingsRepository import com.nuvio.app.features.tracking.TrackingSettingsUiState import com.nuvio.app.features.tracking.WatchProgressSource @@ -94,6 +98,7 @@ import nuvio.composeapp.generated.resources.settings_simkl_missing_credentials import nuvio.composeapp.generated.resources.settings_simkl_open_login import nuvio.composeapp.generated.resources.settings_simkl_sign_in_description import nuvio.composeapp.generated.resources.settings_simkl_sign_in_failed +import nuvio.composeapp.generated.resources.settings_simkl_sync_now import nuvio.composeapp.generated.resources.settings_simkl_visit import nuvio.composeapp.generated.resources.tracking_library_source_simkl_selected import nuvio.composeapp.generated.resources.tracking_source_simkl @@ -781,6 +786,12 @@ private fun SimklConnectionCard( isTablet: Boolean, uiState: SimklAuthUiState, ) { + val syncState by remember { + SimklSyncRepository.ensureLoaded() + SimklSyncRepository.state + }.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + ProviderConnectionCard( isTablet = isTablet, mode = when (uiState.mode) { @@ -801,8 +812,10 @@ private fun SimklConnectionCard( connectLabel = stringResource(Res.string.settings_simkl_connect), openLoginLabel = stringResource(Res.string.settings_simkl_open_login), disconnectLabel = stringResource(Res.string.settings_simkl_disconnect), + syncLabel = stringResource(Res.string.settings_simkl_sync_now), + isSyncing = syncState.isLoading, missingCredentialsMessage = stringResource(Res.string.settings_simkl_missing_credentials), - errorMessage = simklErrorMessage(uiState.error), + errorMessage = simklErrorMessage(uiState.error) ?: syncState.errorMessage, websiteLabel = stringResource(Res.string.settings_simkl_visit), websiteUrl = SIMKL_WEBSITE_URL, onConnectRequested = SimklAuthRepository::onConnectRequested, @@ -811,6 +824,11 @@ private fun SimklConnectionCard( ?: SimklAuthRepository.onConnectRequested() }, onCancelAuthorization = SimklAuthRepository::onCancelAuthorization, + onSyncRequested = { + scope.launch { + SimklSyncRepository.refresh(TrackingRefreshIntent.USER_INITIATED) + } + }, onDisconnect = SimklAuthRepository::onDisconnectRequested, ) } @@ -829,6 +847,8 @@ private fun ProviderConnectionCard( connectLabel: String, openLoginLabel: String, disconnectLabel: String, + syncLabel: String? = null, + isSyncing: Boolean = false, missingCredentialsMessage: String, statusMessage: String? = null, errorMessage: String? = null, @@ -837,6 +857,7 @@ private fun ProviderConnectionCard( onConnectRequested: () -> String?, onResumeAuthorization: () -> String?, onCancelAuthorization: () -> Unit, + onSyncRequested: (() -> Unit)? = null, onDisconnect: () -> Unit, ) { val uriHandler = LocalUriHandler.current @@ -871,9 +892,24 @@ private fun ProviderConnectionCard( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + if (syncLabel != null && onSyncRequested != null) { + Button( + onClick = onSyncRequested, + enabled = !isLoading && !isSyncing, + ) { + if (isSyncing) { + NuvioLoadingIndicator( + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(18.dp), + ) + } else { + Text(syncLabel) + } + } + } Button( onClick = onDisconnect, - enabled = !isLoading, + enabled = !isLoading && !isSyncing, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.surfaceVariant, contentColor = MaterialTheme.colorScheme.onSurface, From 56a496a3846884bb965608ea8eddc90b800eaa90 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:47:53 +0530 Subject: [PATCH 18/59] chore(simkl): log sync HTTP responses --- .../app/features/simkl/SimklApiClient.kt | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt index cac9481a..a3830660 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt @@ -1,5 +1,6 @@ package com.nuvio.app.features.simkl +import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.RawHttpResponse import com.nuvio.app.features.addons.httpRequestRaw import kotlinx.coroutines.CancellationException @@ -13,6 +14,8 @@ import kotlin.math.max import kotlin.random.Random private const val SIMKL_MAX_RESPONSE_BODY_BYTES = 8 * 1024 * 1024 +private const val SIMKL_RESPONSE_LOG_PREVIEW_CHARS = 2_048 +private val simklSyncLog = Logger.withTag("SimklSync") internal enum class SimklHttpMethod { GET, @@ -116,6 +119,12 @@ internal class SimklApiClient( continue } + logSyncReadResponse( + request = request, + response = response, + attempt = attempt + 1, + ) + when (classifySimklResponse(response.status, request.scrobbleStopConflictIsSuccess)) { SimklResponseAction.SUCCESS -> return@withLock response.toApiResponse() SimklResponseAction.SOFT_SUCCESS -> { @@ -169,6 +178,29 @@ internal class SimklApiClient( } } +private fun logSyncReadResponse( + request: SimklApiRequest, + response: RawHttpResponse, + attempt: Int, +) { + if (request.method != SimklHttpMethod.GET || !request.path.startsWith("/sync/")) return + + val preview = response.body + .take(SIMKL_RESPONSE_LOG_PREVIEW_CHARS) + .replace("\r", "\\r") + .replace("\n", "\\n") + .ifEmpty { "" } + val truncationMarker = if (response.body.length > SIMKL_RESPONSE_LOG_PREVIEW_CHARS) "…" else "" + val contentType = response.headers.headerValue("content-type") ?: "" + + simklSyncLog.i { + "Simkl HTTP response: method=${request.method} path=${request.path} " + + "query=${request.query} status=${response.status} attempt=$attempt " + + "contentType=$contentType bodyChars=${response.body.length} " + + "bodyPreview=$preview$truncationMarker" + } +} + internal object SimklApi { val client: SimklApiClient by lazy { SimklApiClient( From e930699ae9d7f2bf64b3286ebb79c7e55353e5fc Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:57:42 +0530 Subject: [PATCH 19/59] fix(simkl): restore compliant library sync --- .../commonMain/kotlin/com/nuvio/app/App.kt | 13 +- .../app/features/details/MetaDetailsScreen.kt | 13 +- .../app/features/library/LibraryRepository.kt | 5 +- .../simkl/SimklApplicationAdapters.kt | 165 -------------- .../app/features/simkl/SimklLibraryAdapter.kt | 215 ++++++++++++++++++ .../features/simkl/SimklLibraryProjection.kt | 115 ++++++++++ .../app/features/simkl/SimklProjections.kt | 36 --- .../app/features/simkl/SimklSyncRemote.kt | 14 +- .../app/features/tracking/TrackingReads.kt | 25 ++ .../features/simkl/SimklProjectionsTest.kt | 57 ++++- .../app/features/simkl/SimklSyncEngineTest.kt | 24 ++ .../features/tracking/TrackingReadsTest.kt | 67 ++++++ 12 files changed, 531 insertions(+), 218 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingReadsTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 26b0abc3..c6dbd361 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -225,6 +225,7 @@ import com.nuvio.app.features.tracking.TrackingScrobbleCoordinator import com.nuvio.app.features.tracking.TrackingScrobbleEvent import com.nuvio.app.features.tracking.buildTrackingMediaReference import com.nuvio.app.features.tracking.TrackingLibraryTab +import com.nuvio.app.features.tracking.toggleTrackingLibraryMembership import com.nuvio.app.features.updater.AppUpdaterHost import com.nuvio.app.features.updater.AppUpdaterPlatform import com.nuvio.app.features.updater.rememberAppUpdaterController @@ -3354,7 +3355,7 @@ private fun MainAppContent( } else { pickerItem = libraryItem pickerTitle = preview.name - pickerTabs = LibraryRepository.libraryListTabs() + pickerTabs = LibraryRepository.libraryListTabs(libraryItem) pickerMembership = pickerTabs.associate { it.key to false } pickerPending = true pickerError = null @@ -3362,7 +3363,7 @@ private fun MainAppContent( coroutineScope.launch { runCatching { val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem) - val tabs = LibraryRepository.libraryListTabs() + val tabs = LibraryRepository.libraryListTabs(libraryItem) pickerTabs = tabs pickerMembership = tabs.associate { tab -> tab.key to (snapshot[tab.key] == true) @@ -3499,9 +3500,11 @@ private fun MainAppContent( isPending = pickerPending, errorMessage = pickerError, onToggle = { listKey -> - pickerMembership = pickerMembership.toMutableMap().apply { - this[listKey] = !(this[listKey] == true) - } + pickerMembership = toggleTrackingLibraryMembership( + tabs = pickerTabs, + membership = pickerMembership, + key = listKey, + ) }, onDismiss = { if (!pickerPending) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index a4932a04..b01c423c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -116,6 +116,7 @@ import com.nuvio.app.features.trakt.TraktCommentsRepository import com.nuvio.app.features.trakt.TraktCommentsSettings import com.nuvio.app.features.trakt.TraktConnectionMode import com.nuvio.app.features.tracking.TrackingLibraryTab +import com.nuvio.app.features.tracking.toggleTrackingLibraryMembership import com.nuvio.app.features.tracking.TrackingSettingsRepository import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.trailer.TrailerPlaybackResolver @@ -416,7 +417,7 @@ fun MetaDetailsScreen( val openLibraryListPicker = remember(meta) { { val libraryItem = meta.toLibraryItem(savedAtEpochMs = 0L) - pickerTabs = LibraryRepository.libraryListTabs() + pickerTabs = LibraryRepository.libraryListTabs(libraryItem) pickerMembership = pickerTabs.associate { it.key to false } pickerPending = true pickerError = null @@ -424,7 +425,7 @@ fun MetaDetailsScreen( detailsScope.launch { runCatching { val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem) - val tabs = LibraryRepository.libraryListTabs() + val tabs = LibraryRepository.libraryListTabs(libraryItem) pickerTabs = tabs pickerMembership = tabs.associate { tab -> tab.key to (snapshot[tab.key] == true) @@ -1220,9 +1221,11 @@ fun MetaDetailsScreen( isPending = pickerPending, errorMessage = pickerError, onToggle = { listKey -> - pickerMembership = pickerMembership.toMutableMap().apply { - this[listKey] = !(this[listKey] == true) - } + pickerMembership = toggleTrackingLibraryMembership( + tabs = pickerTabs, + membership = pickerMembership, + key = listKey, + ) }, onDismiss = { if (!pickerPending) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt index 83851b31..13814988 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt @@ -15,6 +15,7 @@ import com.nuvio.app.features.tracking.TrackingLibraryTabKind import com.nuvio.app.features.tracking.TrackingProviderRegistry import com.nuvio.app.features.tracking.TrackingRefreshIntent import com.nuvio.app.features.tracking.TrackingSettingsRepository +import com.nuvio.app.features.tracking.supportsContentType import com.nuvio.app.features.tracking.effectiveLibrarySourceMode as resolveEffectiveLibrarySourceMode import com.nuvio.app.features.tracking.providerId import io.github.jan.supabase.postgrest.postgrest @@ -370,11 +371,11 @@ object LibraryRepository { return localState.findById(id) } - fun libraryListTabs(): List = + fun libraryListTabs(item: LibraryItem? = null): List = libraryTabsWithLocal( TrackingProviderRegistry.connectedLibraryProviders() .flatMap { provider -> provider.snapshot().tabs }, - ) + ).filter { tab -> item == null || tab.supportsContentType(item.type) } suspend fun getMembershipSnapshot(item: LibraryItem): Map { ensureLoaded() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt index 8eedfa34..e4668ba8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt @@ -1,15 +1,8 @@ package com.nuvio.app.features.simkl import co.touchlab.kermit.Logger -import com.nuvio.app.features.library.LibraryItem -import com.nuvio.app.features.library.LibrarySection import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.tracking.TrackingHistoryItem -import com.nuvio.app.features.tracking.TrackingLibraryProvider -import com.nuvio.app.features.tracking.TrackingLibrarySnapshot -import com.nuvio.app.features.tracking.TrackingLibraryTab -import com.nuvio.app.features.tracking.TrackingLibraryTabKind -import com.nuvio.app.features.tracking.TrackingListStatus import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.tracking.TrackingProgressProvider import com.nuvio.app.features.tracking.TrackingProgressSnapshot @@ -29,154 +22,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -data class SimklLibraryUiState( - val items: List = emptyList(), - val isLoading: Boolean = false, - val hasLoaded: Boolean = false, - val errorMessage: String? = null, -) - -object SimklLibraryRepository { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private val _uiState = MutableStateFlow(SimklLibraryUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - init { - scope.launch { - SimklSyncRepository.state.collectLatest(::publish) - } - } - - fun ensureLoaded() { - SimklAuthRepository.ensureLoaded() - SimklSyncRepository.ensureLoaded() - publish(SimklSyncRepository.state.value) - } - - suspend fun refresh(intent: TrackingRefreshIntent) { - SimklSyncRepository.refresh(intent) - publish(SimklSyncRepository.state.value) - } - - fun isInWatchlist(contentId: String, contentType: String? = null): Boolean { - ensureLoaded() - return uiState.value.items.any { item -> - item.id == contentId && (contentType == null || item.type.equals(contentType, ignoreCase = true)) - } - } - - suspend fun setWatchlistMembership( - profileId: Int, - item: LibraryItem, - isMember: Boolean, - ) { - if (profileId != ProfileRepository.activeProfileId) return - ensureLoaded() - val snapshot = SimklSyncRepository.state.value.snapshot - val media = snapshot.mediaReference( - contentId = item.id, - contentType = item.type, - title = item.name, - releaseInfo = item.releaseInfo, - ) - val result = if (isMember) { - SimklMutationRepository.moveToList( - profileId = profileId, - items = listOf(media), - destination = TrackingListStatus.PLAN_TO_WATCH, - ) - } else { - require(snapshot.canSafelyRemoveFromSimklWatchlist(item.id)) { - "Removing this item from Simkl would also clear watched history or a rating" - } - SimklMutationRepository.removeFromList(profileId = profileId, items = listOf(media)) - } - check(result.isComplete) { - "Simkl could not match ${result.notFoundCount} of ${result.attemptedCount} library items" - } - refresh(TrackingRefreshIntent.INVALIDATED) - } - - private fun publish(syncState: SimklSyncUiState) { - _uiState.value = SimklLibraryUiState( - items = syncState.snapshot.toSimklLibraryItems(), - isLoading = syncState.isLoading, - hasLoaded = syncState.hasLoaded, - errorMessage = syncState.errorMessage, - ) - } -} - -object SimklTrackingLibraryProvider : TrackingLibraryProvider { - override val providerId: TrackingProviderId = TrackingProviderId.SIMKL - override val changes: Flow = SimklLibraryRepository.uiState.map { Unit } - - override fun ensureLoaded() = SimklLibraryRepository.ensureLoaded() - - override fun onProfileChanged() = SimklLibraryRepository.ensureLoaded() - - override suspend fun refresh(intent: TrackingRefreshIntent) = - SimklLibraryRepository.refresh(intent) - - override fun snapshot(): TrackingLibrarySnapshot { - val state = SimklLibraryRepository.uiState.value - val items = state.items.sortedByDescending(LibraryItem::savedAtEpochMs) - return TrackingLibrarySnapshot( - items = items, - sections = listOf( - LibrarySection( - type = SIMKL_WATCHLIST_KEY, - displayTitle = SIMKL_WATCHLIST_TITLE, - items = items, - ), - ), - tabs = listOf( - TrackingLibraryTab( - key = SIMKL_WATCHLIST_KEY, - title = SIMKL_WATCHLIST_TITLE, - providerId = TrackingProviderId.SIMKL, - kind = TrackingLibraryTabKind.WATCHLIST, - ), - ), - hasLoaded = state.hasLoaded, - isLoading = state.isLoading, - errorMessage = state.errorMessage, - ) - } - - override fun contains(contentId: String, contentType: String?): Boolean = - SimklLibraryRepository.isInWatchlist(contentId, contentType) - - override fun find(contentId: String): LibraryItem? = - SimklLibraryRepository.uiState.value.items.firstOrNull { item -> item.id == contentId } - - override suspend fun membership(item: LibraryItem): Map = - mapOf(SIMKL_WATCHLIST_KEY to SimklLibraryRepository.isInWatchlist(item.id, item.type)) - - override suspend fun applyMembership( - profileId: Int, - item: LibraryItem, - desiredMembership: Map, - ) { - val desired = desiredMembership[SIMKL_WATCHLIST_KEY] == true - if (desired != SimklLibraryRepository.isInWatchlist(item.id, item.type)) { - SimklLibraryRepository.setWatchlistMembership( - profileId = profileId, - item = item, - isMember = desired, - ) - } - } - - override suspend fun toggleDefaultMembership(profileId: Int, item: LibraryItem) { - SimklLibraryRepository.setWatchlistMembership( - profileId = profileId, - item = item, - isMember = !SimklLibraryRepository.isInWatchlist(item.id, item.type), - ) - } -} - object SimklWatchedSyncAdapter : TrackingWatchedProvider { override val providerId: TrackingProviderId = TrackingProviderId.SIMKL override suspend fun pull(profileId: Int, pageSize: Int): List { @@ -333,14 +178,4 @@ object SimklTrackingProgressProvider : TrackingProgressProvider { SimklProgressRepository.removeProgress(entries) } -private fun SimklSyncSnapshot.canSafelyRemoveFromSimklWatchlist(contentId: String): Boolean = - entries.firstOrNull { entry -> entry.media?.canonicalContentId().equals(contentId, ignoreCase = true) } - ?.let { entry -> - entry.status == SimklListStatus.PLAN_TO_WATCH && - entry.lastWatchedAt == null && - entry.userRating == null && - entry.seasons.none { season -> season.episodes.any { episode -> episode.watchedAt != null } } - } - ?: true - private const val SIMKL_PLAYBACK_PROGRESS_KEY_PREFIX = "simkl-playback:" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt new file mode 100644 index 00000000..674a3baa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt @@ -0,0 +1,215 @@ +package com.nuvio.app.features.simkl + +import com.nuvio.app.features.library.LibraryItem +import com.nuvio.app.features.library.LibrarySection +import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.tracking.TrackingLibraryProvider +import com.nuvio.app.features.tracking.TrackingLibrarySnapshot +import com.nuvio.app.features.tracking.TrackingLibraryTab +import com.nuvio.app.features.tracking.TrackingLibraryTabKind +import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.TrackingRefreshIntent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +data class SimklLibraryUiState( + val items: List = emptyList(), + val sections: List = emptyList(), + val isLoading: Boolean = false, + val hasLoaded: Boolean = false, + val errorMessage: String? = null, +) + +object SimklLibraryRepository { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val _uiState = MutableStateFlow(SimklLibraryUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + scope.launch { + SimklSyncRepository.state.collectLatest(::publish) + } + } + + fun ensureLoaded() { + SimklAuthRepository.ensureLoaded() + SimklSyncRepository.ensureLoaded() + publish(SimklSyncRepository.state.value) + } + + suspend fun refresh(intent: TrackingRefreshIntent) { + SimklSyncRepository.refresh(intent) + publish(SimklSyncRepository.state.value) + } + + fun isTracked(contentId: String, contentType: String? = null): Boolean { + ensureLoaded() + return findItem(contentId, contentType) != null + } + + fun statusMembership(contentId: String, contentType: String? = null): Map { + ensureLoaded() + val listKeys = findItem(contentId, contentType)?.listKeys.orEmpty() + return simklLibraryStatusDefinitions.associate { definition -> + definition.key to (definition.key in listKeys) + } + } + + suspend fun applyStatusMembership( + profileId: Int, + item: LibraryItem, + desiredMembership: Map, + ) { + if (profileId != ProfileRepository.activeProfileId) return + ensureLoaded() + val desiredStatuses = simklLibraryStatusDefinitions.filter { definition -> + desiredMembership[definition.key] == true + } + require(desiredStatuses.size <= 1) { "A Simkl item can have only one list status" } + val desiredStatus = desiredStatuses.singleOrNull() + require(desiredStatus == null || desiredStatus.supportedContentTypes.any { supported -> + supported.equals(item.type, ignoreCase = true) + }) { "${desiredStatus?.title} does not support ${item.type}" } + val currentStatus = findItem(item.id, item.type)?.listKeys.orEmpty() + .firstNotNullOfOrNull(::simklLibraryStatusDefinition) + if (desiredStatus == currentStatus) return + + val snapshot = SimklSyncRepository.state.value.snapshot + val media = snapshot.mediaReference( + contentId = item.id, + contentType = item.type, + title = item.name, + releaseInfo = item.releaseInfo, + ) + val result = when { + desiredStatus != null -> SimklMutationRepository.moveToList( + profileId = profileId, + items = listOf(media), + destination = desiredStatus.trackingStatus, + ) + + currentStatus != null -> { + require(snapshot.canSafelyRemoveFromSimklLibrary(item.id)) { + "Removing this item from Simkl would also clear watched history or a rating" + } + SimklMutationRepository.removeFromList(profileId = profileId, items = listOf(media)) + } + + else -> return + } + check(result.isComplete) { + "Simkl could not match ${result.notFoundCount} of ${result.attemptedCount} library items" + } + refresh(TrackingRefreshIntent.INVALIDATED) + } + + private fun publish(syncState: SimklSyncUiState) { + val projection = syncState.snapshot.toSimklLibraryProjection() + _uiState.value = SimklLibraryUiState( + items = projection.items, + sections = projection.sections, + isLoading = syncState.isLoading, + hasLoaded = syncState.hasLoaded, + errorMessage = syncState.errorMessage, + ) + } + + private fun findItem(contentId: String, contentType: String?): LibraryItem? = + uiState.value.items.firstOrNull { item -> + item.id.equals(contentId, ignoreCase = true) && + (contentType == null || item.type.equals(contentType, ignoreCase = true)) + } +} + +object SimklTrackingLibraryProvider : TrackingLibraryProvider { + override val providerId: TrackingProviderId = TrackingProviderId.SIMKL + override val changes: Flow = SimklLibraryRepository.uiState.map { Unit } + + override fun ensureLoaded() = SimklLibraryRepository.ensureLoaded() + + override fun onProfileChanged() = SimklLibraryRepository.ensureLoaded() + + override suspend fun refresh(intent: TrackingRefreshIntent) = + SimklLibraryRepository.refresh(intent) + + override fun snapshot(): TrackingLibrarySnapshot { + val state = SimklLibraryRepository.uiState.value + return TrackingLibrarySnapshot( + items = state.items.sortedByDescending(LibraryItem::savedAtEpochMs), + sections = state.sections, + tabs = simklLibraryStatusDefinitions.map { definition -> + TrackingLibraryTab( + key = definition.key, + title = definition.title, + providerId = TrackingProviderId.SIMKL, + kind = if (definition.status == SimklListStatus.PLAN_TO_WATCH) { + TrackingLibraryTabKind.WATCHLIST + } else { + TrackingLibraryTabKind.STATUS + }, + selectionGroup = SIMKL_STATUS_SELECTION_GROUP, + supportedContentTypes = definition.supportedContentTypes, + ) + }, + hasLoaded = state.hasLoaded, + isLoading = state.isLoading, + errorMessage = state.errorMessage, + ) + } + + override fun contains(contentId: String, contentType: String?): Boolean = + SimklLibraryRepository.isTracked(contentId, contentType) + + override fun find(contentId: String): LibraryItem? = + SimklLibraryRepository.uiState.value.items.firstOrNull { item -> + item.id.equals(contentId, ignoreCase = true) + } + + override suspend fun membership(item: LibraryItem): Map = + SimklLibraryRepository.statusMembership(item.id, item.type) + + override suspend fun applyMembership( + profileId: Int, + item: LibraryItem, + desiredMembership: Map, + ) { + SimklLibraryRepository.applyStatusMembership( + profileId = profileId, + item = item, + desiredMembership = desiredMembership, + ) + } + + override suspend fun toggleDefaultMembership(profileId: Int, item: LibraryItem) { + val current = SimklLibraryRepository.statusMembership(item.id, item.type) + val desired = current.mapValues { false }.toMutableMap() + if (current.values.none { isSelected -> isSelected }) { + desired[simklLibraryStatusDefinitions.single { definition -> + definition.status == SimklListStatus.PLAN_TO_WATCH + }.key] = true + } + SimklLibraryRepository.applyStatusMembership( + profileId = profileId, + item = item, + desiredMembership = desired, + ) + } +} + +private fun SimklSyncSnapshot.canSafelyRemoveFromSimklLibrary(contentId: String): Boolean = + entries.firstOrNull { entry -> entry.media?.canonicalContentId().equals(contentId, ignoreCase = true) } + ?.let { entry -> + entry.status == SimklListStatus.PLAN_TO_WATCH && + entry.lastWatchedAt == null && + entry.userRating == null && + entry.seasons.none { season -> season.episodes.any { episode -> episode.watchedAt != null } } + } + ?: true diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt new file mode 100644 index 00000000..497b0f8d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt @@ -0,0 +1,115 @@ +package com.nuvio.app.features.simkl + +import com.nuvio.app.features.home.PosterShape +import com.nuvio.app.features.library.LibraryItem +import com.nuvio.app.features.library.LibrarySection +import com.nuvio.app.features.tracking.TrackingListStatus + +internal const val SIMKL_STATUS_SELECTION_GROUP = "simkl:status" + +internal data class SimklLibraryStatusDefinition( + val status: SimklListStatus, + val key: String, + val title: String, + val trackingStatus: TrackingListStatus, + val supportedContentTypes: Set, +) + +internal data class SimklLibraryProjection( + val items: List, + val sections: List, +) + +internal val simklLibraryStatusDefinitions = listOf( + SimklLibraryStatusDefinition( + status = SimklListStatus.WATCHING, + key = "simkl:status:watching", + title = "Simkl Watching", + trackingStatus = TrackingListStatus.WATCHING, + supportedContentTypes = setOf("series"), + ), + SimklLibraryStatusDefinition( + status = SimklListStatus.PLAN_TO_WATCH, + key = "simkl:status:plantowatch", + title = "Simkl Plan to Watch", + trackingStatus = TrackingListStatus.PLAN_TO_WATCH, + supportedContentTypes = setOf("movie", "series"), + ), + SimklLibraryStatusDefinition( + status = SimklListStatus.ON_HOLD, + key = "simkl:status:hold", + title = "Simkl On Hold", + trackingStatus = TrackingListStatus.ON_HOLD, + supportedContentTypes = setOf("series"), + ), + SimklLibraryStatusDefinition( + status = SimklListStatus.COMPLETED, + key = "simkl:status:completed", + title = "Simkl Completed", + trackingStatus = TrackingListStatus.COMPLETED, + supportedContentTypes = setOf("movie", "series"), + ), + SimklLibraryStatusDefinition( + status = SimklListStatus.DROPPED, + key = "simkl:status:dropped", + title = "Simkl Dropped", + trackingStatus = TrackingListStatus.DROPPED, + supportedContentTypes = setOf("movie", "series"), + ), +) + +internal fun SimklSyncSnapshot.toSimklLibraryProjection(): SimklLibraryProjection { + val sections = simklLibraryStatusDefinitions.mapNotNull { definition -> + val items = entries + .asSequence() + .filter { entry -> entry.status == definition.status } + .mapNotNull { entry -> entry.toLibraryItem(definition.key, lastSyncedAtEpochMs) } + .distinctBy { item -> "${item.type}:${item.id}" } + .sortedByDescending(LibraryItem::savedAtEpochMs) + .toList() + items.takeIf { projectedItems -> projectedItems.isNotEmpty() }?.let { + LibrarySection( + type = definition.key, + displayTitle = definition.title, + items = items, + ) + } + } + return SimklLibraryProjection( + items = sections + .flatMap(LibrarySection::items) + .distinctBy { item -> "${item.type}:${item.id}" } + .sortedByDescending(LibraryItem::savedAtEpochMs), + sections = sections, + ) +} + +internal fun simklLibraryStatusDefinition(key: String): SimklLibraryStatusDefinition? = + simklLibraryStatusDefinitions.firstOrNull { definition -> definition.key == key } + +private fun SimklLibraryEntry.toLibraryItem( + listKey: String, + lastSyncedAtEpochMs: Long?, +): LibraryItem? { + val media = media ?: return null + val contentId = media.canonicalContentId() ?: return null + val simklId = media.ids.simklIdValue()?.toLongOrNull() + return LibraryItem( + id = contentId, + type = if (mediaType == SimklMediaType.MOVIES) "movie" else "series", + name = media.title?.takeIf(String::isNotBlank) ?: contentId, + poster = simklPosterUrl(media.poster), + releaseInfo = media.year?.toString(), + posterShape = PosterShape.Poster, + listKeys = setOf(listKey), + imdbId = media.ids.idValue("imdb"), + tmdbId = media.ids.idValue("tmdb")?.toIntOrNull(), + trackingProviderId = "simkl", + trackingProviderItemId = simklId?.let { "simkl:$it" }, + trackingSourceUrl = buildSimklSourceUrl(mediaType, media), + savedAtEpochMs = parseSimklUtcEpochMs(addedToWatchlistAt) + ?: parseSimklUtcEpochMs(lastWatchedAt) + ?: lastSyncedAtEpochMs + ?: 0L, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt index 54c739f4..699675d7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt @@ -1,7 +1,5 @@ package com.nuvio.app.features.simkl -import com.nuvio.app.features.home.PosterShape -import com.nuvio.app.features.library.LibraryItem import com.nuvio.app.features.tracking.TrackingEpisode import com.nuvio.app.features.tracking.TrackingExternalIds import com.nuvio.app.features.tracking.TrackingMediaKind @@ -15,22 +13,11 @@ import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.WatchProgressSourceSimklPlayback import com.nuvio.app.features.watchprogress.buildPlaybackVideoId -internal const val SIMKL_WATCHLIST_KEY = "simkl:watchlist" -internal const val SIMKL_WATCHLIST_TITLE = "Simkl Watchlist" - internal data class SimklWatchedProjection( val items: List, val fullyWatchedSeriesKeys: Set, ) -internal fun SimklSyncSnapshot.toSimklLibraryItems(): List = - entries - .asSequence() - .filter { entry -> entry.status == SimklListStatus.PLAN_TO_WATCH } - .mapNotNull { entry -> entry.toLibraryItem(lastSyncedAtEpochMs) } - .sortedByDescending(LibraryItem::savedAtEpochMs) - .toList() - internal fun SimklSyncSnapshot.toSimklWatchedProjection(): SimklWatchedProjection { val watchedItems = mutableListOf() val fullyWatched = linkedSetOf() @@ -227,29 +214,6 @@ internal fun parseSimklUtcEpochMs(value: String?): Long? { return (((days * 24L + hour) * 60L + minute) * 60L + second) * 1_000L + millis } -private fun SimklLibraryEntry.toLibraryItem(lastSyncedAtEpochMs: Long?): LibraryItem? { - val media = media ?: return null - val contentId = media.canonicalContentId() ?: return null - val simklId = media.ids.simklIdValue()?.toLongOrNull() - return LibraryItem( - id = contentId, - type = if (mediaType == SimklMediaType.MOVIES) "movie" else "series", - name = media.title?.takeIf(String::isNotBlank) ?: contentId, - poster = simklPosterUrl(media.poster), - releaseInfo = media.year?.toString(), - posterShape = PosterShape.Poster, - listKeys = setOf(SIMKL_WATCHLIST_KEY), - imdbId = media.ids.idValue("imdb"), - tmdbId = media.ids.idValue("tmdb")?.toIntOrNull(), - trackingProviderId = "simkl", - trackingProviderItemId = simklId?.let { "simkl:$it" }, - trackingSourceUrl = buildSimklSourceUrl(mediaType, media), - savedAtEpochMs = parseSimklUtcEpochMs(addedToWatchlistAt) - ?: lastSyncedAtEpochMs - ?: 0L, - ) -} - private fun SimklPlaybackSession.toWatchProgressEntry(): WatchProgressEntry? { val media = media ?: return null val parentId = media.canonicalContentId() ?: return null 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 index 489daf16..9279b073 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRemote.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRemote.kt @@ -2,6 +2,9 @@ package com.nuvio.app.features.simkl import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.decodeFromJsonElement internal data class SimklAllItemsRequest( val type: SimklMediaType? = null, @@ -53,7 +56,7 @@ internal class SimklApiSyncRemote( path = path, query = query, ), - ).body.decode() + ).body.decodeAllItems() } override suspend fun fetchPlayback(): List = @@ -65,4 +68,13 @@ internal class SimklApiSyncRemote( ).body.decode() private inline fun String.decode(): T = json.decodeFromString(this) + + private fun String.decodeAllItems(): SimklAllItemsResponse { + val element = json.parseToJsonElement(this) + return when { + element is JsonNull -> SimklAllItemsResponse() + element is JsonArray && element.isEmpty() -> SimklAllItemsResponse() + else -> json.decodeFromJsonElement(element) + } + } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt index 5ee808b2..cf4be3f7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.Flow enum class TrackingLibraryTabKind { WATCHLIST, PERSONAL, + STATUS, } data class TrackingLibraryTab( @@ -17,8 +18,32 @@ data class TrackingLibraryTab( val title: String, val providerId: TrackingProviderId?, val kind: TrackingLibraryTabKind, + val selectionGroup: String? = null, + val supportedContentTypes: Set? = null, ) +fun TrackingLibraryTab.supportsContentType(contentType: String): Boolean = + supportedContentTypes == null || supportedContentTypes.any { supported -> + supported.equals(contentType, ignoreCase = true) + } + +fun toggleTrackingLibraryMembership( + tabs: List, + membership: Map, + key: String, +): Map { + val target = tabs.firstOrNull { tab -> tab.key == key } ?: return membership + val selecting = membership[key] != true + return membership.toMutableMap().apply { + if (selecting && target.selectionGroup != null) { + tabs.filter { tab -> + tab.providerId == target.providerId && tab.selectionGroup == target.selectionGroup + }.forEach { tab -> this[tab.key] = false } + } + this[key] = selecting + } +} + data class TrackingLibrarySnapshot( val items: List = emptyList(), val sections: List = emptyList(), diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt index 118204e7..6fa8e2a8 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt @@ -13,7 +13,7 @@ import kotlin.test.assertTrue class SimklProjectionsTest { @Test - fun `library projection exposes only plan to watch with attribution`() { + fun `library projection exposes every populated status with attribution`() { val plan = entry( type = SimklMediaType.MOVIES, status = SimklListStatus.PLAN_TO_WATCH, @@ -28,12 +28,32 @@ class SimklProjectionsTest { id = 53434, imdb = "tt0068646", ) + val watching = entry( + type = SimklMediaType.SHOWS, + status = SimklListStatus.WATCHING, + id = 2090, + imdb = "tt1520211", + ) - val items = SimklSyncSnapshot(entries = listOf(plan, completed)).toSimklLibraryItems() + val projection = SimklSyncSnapshot(entries = listOf(plan, completed, watching)).toSimklLibraryProjection() + val watchingDefinition = simklLibraryStatusDefinitions.single { definition -> + definition.status == SimklListStatus.WATCHING + } + val planDefinition = simklLibraryStatusDefinitions.single { definition -> + definition.status == SimklListStatus.PLAN_TO_WATCH + } + val completedDefinition = simklLibraryStatusDefinitions.single { definition -> + definition.status == SimklListStatus.COMPLETED + } - val item = items.single() + assertEquals( + listOf(watchingDefinition.key, planDefinition.key, completedDefinition.key), + projection.sections.map { section -> section.type }, + ) + assertEquals(3, projection.items.size) + val item = projection.items.single { candidate -> candidate.id == "tt0181852" } assertEquals("tt0181852", item.id) - assertEquals(setOf(SIMKL_WATCHLIST_KEY), item.listKeys) + assertEquals(setOf(planDefinition.key), item.listKeys) assertEquals("simkl", item.trackingProviderId) assertEquals("simkl:53536", item.trackingProviderItemId) assertEquals( @@ -42,6 +62,14 @@ class SimklProjectionsTest { ) assertTrue(item.poster.orEmpty().contains("simkl.in/posters/12/poster_w.webp")) assertEquals(1_700_000_000_000L, item.savedAtEpochMs) + assertEquals( + setOf(completedDefinition.key), + projection.items.single { candidate -> candidate.id == "tt0068646" }.listKeys, + ) + assertEquals( + setOf(watchingDefinition.key), + projection.items.single { candidate -> candidate.id == "tt1520211" }.listKeys, + ) } @Test @@ -89,6 +117,27 @@ class SimklProjectionsTest { assertTrue(projection.fullyWatchedSeriesKeys.any { "tt2560140" in it }) } + @Test + fun `summary counters do not fabricate exact episode markers`() { + val summary = entry( + type = SimklMediaType.SHOWS, + status = SimklListStatus.WATCHING, + id = 2090, + imdb = "tt1520211", + lastWatchedAt = "2023-11-14T23:13:20Z", + ).copy( + lastWatched = "S01E03", + nextToWatch = "S01E04", + watchedEpisodesCount = 3, + totalEpisodesCount = 6, + ) + + val projection = SimklSyncSnapshot(entries = listOf(summary)).toSimklWatchedProjection() + + assertTrue(projection.items.isEmpty()) + assertTrue(projection.fullyWatchedSeriesKeys.isEmpty()) + } + @Test fun `playback projection preserves Simkl session identity and percentage`() { val session = SimklPlaybackSession( 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 index 89a0db68..0a02dfb3 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSyncEngineTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSyncEngineTest.kt @@ -174,6 +174,30 @@ class SimklSyncEngineTest { assertTrue("extended=simkl_ids_only" in urls[2]) } + @Test + fun `remote treats top level empty all items variants as empty`() = runBlocking { + listOf("null", "[]").forEach { body -> + val engine = SimklHttpEngine { _, url, _, _ -> + RawHttpResponse(200, "", url, body, emptyMap()) + } + val client = SimklApiClient( + engine = engine, + accessToken = { "token" }, + onUnauthorized = {}, + nowEpochMs = { 0L }, + sleep = {}, + retryJitterMs = { 0L }, + ) + + val response = SimklApiSyncRemote(client).fetchAllItems( + SimklAllItemsRequest(type = SimklMediaType.ANIME), + ) + + assertTrue(response.entriesFor(SimklMediaType.ANIME).isEmpty()) + assertTrue(response.presentTypes().isEmpty()) + } + } + private sealed interface Step { data class Activities(val value: SimklActivities) : Step data class AllItems(val type: SimklMediaType?, val value: SimklAllItemsResponse) : Step diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingReadsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingReadsTest.kt new file mode 100644 index 00000000..030ce7d5 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingReadsTest.kt @@ -0,0 +1,67 @@ +package com.nuvio.app.features.tracking + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class TrackingReadsTest { + @Test + fun `selection group keeps one provider status selected`() { + val tabs = listOf( + tab("local", providerId = null), + tab("simkl:watching", selectionGroup = "simkl:status"), + tab("simkl:completed", selectionGroup = "simkl:status"), + ) + val current = mapOf( + "local" to true, + "simkl:watching" to true, + "simkl:completed" to false, + ) + + val updated = toggleTrackingLibraryMembership( + tabs = tabs, + membership = current, + key = "simkl:completed", + ) + + assertEquals(true, updated["local"]) + assertEquals(false, updated["simkl:watching"]) + assertEquals(true, updated["simkl:completed"]) + } + + @Test + fun `unknown selection key leaves membership unchanged`() { + val current = mapOf("local" to true) + + val updated = toggleTrackingLibraryMembership( + tabs = listOf(tab("local", providerId = null)), + membership = current, + key = "missing", + ) + + assertSame(current, updated) + } + + @Test + fun `content type support filters provider specific statuses`() { + val seriesOnly = tab("simkl:watching").copy(supportedContentTypes = setOf("series")) + + assertTrue(seriesOnly.supportsContentType("SERIES")) + assertFalse(seriesOnly.supportsContentType("movie")) + assertTrue(tab("trakt:watchlist").supportsContentType("movie")) + } + + private fun tab( + key: String, + providerId: TrackingProviderId? = TrackingProviderId.SIMKL, + selectionGroup: String? = null, + ) = TrackingLibraryTab( + key = key, + title = key, + providerId = providerId, + kind = TrackingLibraryTabKind.STATUS, + selectionGroup = selectionGroup, + ) +} From e408a2f48caa556a97adf72ad389096f9f1caa24 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:18:30 +0530 Subject: [PATCH 20/59] fix(progress): isolate continue watching sources --- .../app/features/tracking/TrackingReads.kt | 1 - .../trakt/TraktTrackingProgressProvider.kt | 3 - .../watchprogress/WatchProgressRepository.kt | 40 +++---------- .../WatchProgressSourceProjection.kt | 13 +++++ .../WatchProgressIdentityTest.kt | 30 ---------- .../WatchProgressSourceProjectionTest.kt | 58 +++++++++++++++++++ 6 files changed, 79 insertions(+), 66 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceProjection.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceProjectionTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt index cf4be3f7..373109b3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt @@ -139,7 +139,6 @@ interface TrackingProgressProvider { fun applyOptimisticRemoval(entries: Collection) = Unit fun applyOptimisticProgress(entry: WatchProgressEntry) = Unit fun normalizeParentContentId(parentContentId: String, videoId: String?): String = parentContentId - fun shouldRetainLocalEntry(entry: WatchProgressEntry): Boolean = true suspend fun refreshEpisodeProgress(contentId: String, forceRefresh: Boolean) = Unit fun isHiddenFromProgress(contentId: String): Boolean = false } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingProgressProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingProgressProvider.kt index c0805b33..17607054 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingProgressProvider.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingProgressProvider.kt @@ -131,9 +131,6 @@ object TraktTrackingProgressProvider : TrackingProgressProvider { override fun normalizeParentContentId(parentContentId: String, videoId: String?): String = resolveEffectiveContentId(parentContentId, videoId) - override fun shouldRetainLocalEntry(entry: WatchProgressEntry): Boolean = - !isTraktCompatibleId(entry.parentMetaId) - override suspend fun refreshEpisodeProgress(contentId: String, forceRefresh: Boolean) = TraktProgressRepository.refreshEpisodeProgress(contentId, forceRefresh) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index 37ab0534..825957ab 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -68,34 +68,6 @@ private data class MetadataProviderReadiness( get() = providers.isNotEmpty() } -internal fun mergeTrackerProgressEntries( - remoteEntries: Collection, - localEntries: Collection, -): List { - val newestByMedia = linkedMapOf() - remoteEntries.forEach { entry -> - newestByMedia[entry.trackerMediaIdentity()] = entry - } - localEntries.forEach { entry -> - val key = entry.trackerMediaIdentity() - val existing = newestByMedia[key] - if (existing == null || entry.lastUpdatedEpochMs > existing.lastUpdatedEpochMs) { - newestByMedia[key] = entry - } - } - return newestByMedia.values.toList() -} - -private fun WatchProgressEntry.trackerMediaIdentity(): String = buildString { - append(parentMetaType.trim().lowercase()) - append(':') - append(parentMetaId.trim().lowercase()) - append(':') - append(seasonNumber ?: -1) - append(':') - append(episodeNumber ?: -1) -} - internal class MetadataResolutionRetryCoordinator { private val lock = SynchronizedObject() private var generation = 0L @@ -1489,10 +1461,14 @@ object WatchProgressRepository { } private fun currentEntries(): List { - val provider = activeProgressProvider() ?: return localEntriesSnapshot() - return mergeTrackerProgressEntries( - remoteEntries = provider.snapshot().entries, - localEntries = localEntriesSnapshot().filter(provider::shouldRetainLocalEntry), + val providerEntries = activeProgressProvider() + ?.snapshot() + ?.entries + .orEmpty() + return projectWatchProgressSourceEntries( + source = activeSource, + nuvioEntries = localEntriesSnapshot(), + providerEntries = providerEntries, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceProjection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceProjection.kt new file mode 100644 index 00000000..121ae3ea --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceProjection.kt @@ -0,0 +1,13 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.features.tracking.WatchProgressSource + +internal fun projectWatchProgressSourceEntries( + source: WatchProgressSource, + nuvioEntries: Collection, + providerEntries: Collection, +): List = if (source.providerId == null) { + nuvioEntries.toList() +} else { + providerEntries.toList() +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt index a75017e7..39003816 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt @@ -9,36 +9,6 @@ import kotlin.test.assertTrue class WatchProgressIdentityTest { - @Test - fun `tracker progress merge keeps one entry per media and favors fresher local playback`() { - val remote = entry( - progressKey = "simkl-playback:42", - lastUpdatedEpochMs = 100L, - lastPositionMs = 400L, - ) - val staleLocal = entry(lastUpdatedEpochMs = 90L, lastPositionMs = 300L) - val otherLocal = entry( - parentMetaId = "other", - videoId = "other:1:2", - lastUpdatedEpochMs = 80L, - ) - - val firstMerge = mergeTrackerProgressEntries( - remoteEntries = listOf(remote), - localEntries = listOf(staleLocal, otherLocal), - ) - - assertEquals(2, firstMerge.size) - assertEquals("simkl-playback:42", firstMerge.single { it.parentMetaId == "show" }.progressKey) - - val freshLocal = staleLocal.copy(lastUpdatedEpochMs = 110L, lastPositionMs = 500L) - val secondMerge = mergeTrackerProgressEntries( - remoteEntries = listOf(remote), - localEntries = listOf(freshLocal), - ) - assertEquals(500L, secondMerge.single().lastPositionMs) - } - @Test fun `provider change during metadata batch schedules one follow up`() { val coordinator = MetadataResolutionRetryCoordinator() diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceProjectionTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceProjectionTest.kt new file mode 100644 index 00000000..c58747cc --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceProjectionTest.kt @@ -0,0 +1,58 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.features.tracking.WatchProgressSource +import kotlin.test.Test +import kotlin.test.assertEquals + +class WatchProgressSourceProjectionTest { + @Test + fun `remote source excludes every Nuvio progress entry`() { + val nuvioEntries = listOf( + entry(parentMetaId = "shared", updatedAt = 200L), + entry(parentMetaId = "nuvio-only", updatedAt = 300L), + ) + val providerEntries = listOf( + entry(parentMetaId = "shared", updatedAt = 100L), + ) + + listOf(WatchProgressSource.TRAKT, WatchProgressSource.SIMKL).forEach { source -> + val projected = projectWatchProgressSourceEntries( + source = source, + nuvioEntries = nuvioEntries, + providerEntries = providerEntries, + ) + + assertEquals(providerEntries, projected) + } + } + + @Test + fun `Nuvio source excludes every provider progress entry`() { + val nuvioEntries = listOf(entry(parentMetaId = "nuvio")) + val providerEntries = listOf(entry(parentMetaId = "provider")) + + val projected = projectWatchProgressSourceEntries( + source = WatchProgressSource.NUVIO_SYNC, + nuvioEntries = nuvioEntries, + providerEntries = providerEntries, + ) + + assertEquals(nuvioEntries, projected) + } + + private fun entry( + parentMetaId: String, + updatedAt: Long = 1L, + ): WatchProgressEntry = WatchProgressEntry( + contentType = "series", + parentMetaId = parentMetaId, + parentMetaType = "series", + videoId = "$parentMetaId:1:1", + title = parentMetaId, + seasonNumber = 1, + episodeNumber = 1, + lastPositionMs = 10L, + durationMs = 100L, + lastUpdatedEpochMs = updatedAt, + ) +} From e32445238d7b27e78e16cc031562fbfa6e6fe3cd Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:44:49 +0530 Subject: [PATCH 21/59] fix(simkl): enrich progress and correct artwork --- .../app/features/simkl/SimklProjections.kt | 2 +- .../WatchProgressMetadataProjection.kt | 87 +++++++++++++++++ .../watchprogress/WatchProgressRepository.kt | 95 ++++++++++--------- .../features/simkl/SimklProjectionsTest.kt | 4 +- .../WatchProgressMetadataProjectionTest.kt | 95 +++++++++++++++++++ 5 files changed, 234 insertions(+), 49 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressMetadataProjection.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressMetadataProjectionTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt index 699675d7..f4a8ea3b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt @@ -174,7 +174,7 @@ internal fun simklPosterUrl(path: String?): String? = ?.trim() ?.trim('/') ?.takeIf(String::isNotBlank) - ?.let { normalized -> "https://wsrv.nl/?url=https://simkl.in/posters/${normalized}_w.webp&q=90" } + ?.let { normalized -> "https://wsrv.nl/?url=https://simkl.in/posters/${normalized}_ca.webp&q=90" } internal fun buildSimklSourceUrl(mediaType: SimklMediaType, media: SimklMedia): String? { val id = media.ids.simklIdValue()?.toLongOrNull()?.takeIf { it > 0L } ?: return null diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressMetadataProjection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressMetadataProjection.kt new file mode 100644 index 00000000..77e3d0ce --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressMetadataProjection.kt @@ -0,0 +1,87 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.features.details.MetaDetails +import com.nuvio.app.features.tracking.WatchProgressSource +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized + +internal data class WatchProgressMetadataKey( + val metaId: String, + val metaType: String, +) + +internal fun WatchProgressEntry.metadataKey(): WatchProgressMetadataKey = WatchProgressMetadataKey( + metaId = parentMetaId, + metaType = parentMetaType.ifBlank { contentType }, +) + +internal fun enrichWatchProgressEntry( + current: WatchProgressEntry, + meta: MetaDetails, +): WatchProgressEntry { + val episodeVideo = if (current.seasonNumber != null && current.episodeNumber != null) { + meta.videos.firstOrNull { video -> + video.season == current.seasonNumber && video.episode == current.episodeNumber + } + } else { + null + } + return current.copy( + title = meta.name.takeIf(String::isNotBlank) ?: current.title, + poster = meta.poster?.takeIf(String::isNotBlank) ?: current.poster, + background = meta.background?.takeIf(String::isNotBlank) ?: current.background, + logo = meta.logo?.takeIf(String::isNotBlank) ?: current.logo, + episodeTitle = episodeVideo?.title?.takeIf(String::isNotBlank) ?: current.episodeTitle, + episodeThumbnail = episodeVideo?.thumbnail?.takeIf(String::isNotBlank) ?: current.episodeThumbnail, + pauseDescription = episodeVideo?.overview?.takeIf(String::isNotBlank) + ?: meta.description?.takeIf(String::isNotBlank) + ?: current.pauseDescription, + ) +} + +internal fun WatchProgressEntry.needsRemoteMetadataEnrichment(): Boolean = + title.isBlank() || + title.equals(parentMetaId, ignoreCase = true) || + poster.isNullOrBlank() || + background.isNullOrBlank() + +internal class ProviderProgressMetadataOverlay { + private val lock = SynchronizedObject() + private var source: WatchProgressSource? = null + private val metadataByKey = mutableMapOf() + + fun clear() { + synchronized(lock) { + source = null + metadataByKey.clear() + } + } + + fun put( + source: WatchProgressSource, + key: WatchProgressMetadataKey, + metadata: MetaDetails, + ): Boolean = synchronized(lock) { + if (this.source != source) { + this.source = source + metadataByKey.clear() + } + val previous = metadataByKey.put(key, metadata) + previous != metadata + } + + fun project( + source: WatchProgressSource, + entries: Collection, + ): List { + val metadata = synchronized(lock) { + if (this.source == source) metadataByKey.toMap() else emptyMap() + } + if (metadata.isEmpty()) return entries.toList() + return entries.map { entry -> + metadata[entry.metadataKey()] + ?.let { meta -> enrichWatchProgressEntry(current = entry, meta = meta) } + ?: entry + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index 825957ab..8dbe2339 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -54,6 +54,7 @@ private const val WATCH_PROGRESS_DELTA_OPERATION_UPSERT = "upsert" private const val WATCH_PROGRESS_DELTA_OPERATION_DELETE = "delete" private data class RemoteMetadataResolutionResult( + val key: WatchProgressMetadataKey, val entries: List, val meta: MetaDetails?, ) @@ -177,36 +178,6 @@ internal data class WatchProgressDeltaDecision( val clearsDirtyProgress: Boolean = false, ) -internal fun enrichWatchProgressEntry( - current: WatchProgressEntry, - meta: MetaDetails, -): WatchProgressEntry { - val episodeVideo = if (current.seasonNumber != null && current.episodeNumber != null) { - meta.videos.firstOrNull { video -> - video.season == current.seasonNumber && video.episode == current.episodeNumber - } - } else { - null - } - return current.copy( - title = meta.name.takeIf(String::isNotBlank) ?: current.title, - poster = meta.poster?.takeIf(String::isNotBlank) ?: current.poster, - background = meta.background?.takeIf(String::isNotBlank) ?: current.background, - logo = meta.logo?.takeIf(String::isNotBlank) ?: current.logo, - episodeTitle = episodeVideo?.title?.takeIf(String::isNotBlank) ?: current.episodeTitle, - episodeThumbnail = episodeVideo?.thumbnail?.takeIf(String::isNotBlank) ?: current.episodeThumbnail, - pauseDescription = episodeVideo?.overview?.takeIf(String::isNotBlank) - ?: meta.description?.takeIf(String::isNotBlank) - ?: current.pauseDescription, - ) -} - -internal fun WatchProgressEntry.needsRemoteMetadataEnrichment(): Boolean = - title.isBlank() || - title.equals(parentMetaId, ignoreCase = true) || - poster.isNullOrBlank() || - background.isNullOrBlank() - object WatchProgressRepository { private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val accountScopeLock = SynchronizedObject() @@ -229,6 +200,7 @@ object WatchProgressRepository { private var dirtyProgressKeys: MutableSet = mutableSetOf() private var metadataResolutionJob: Job? = null private val metadataResolutionRetryCoordinator = MetadataResolutionRetryCoordinator() + private val providerMetadataOverlay = ProviderProgressMetadataOverlay() private val nuvioPullMutex = Mutex() private var lastSuccessfulPushEpochMs = 0L private var deltaCursorEventId = 0L @@ -242,6 +214,9 @@ object WatchProgressRepository { provider.changes.collectLatest { if (activeSource.providerId == provider.providerId) { publish() + if (hasLoaded && !provider.providesCompleteMetadata) { + resolveRemoteMetadata() + } } } } @@ -297,6 +272,7 @@ object WatchProgressRepository { currentProfileId = 1 profileGeneration += 1L updateActiveSource(WatchProgressSource.NUVIO_SYNC) + providerMetadataOverlay.clear() clearLocalEntries() lastSuccessfulPushEpochMs = 0L deltaCursorEventId = 0L @@ -311,6 +287,7 @@ object WatchProgressRepository { profileGeneration += 1L hasLoaded = true hasLoadedNuvioRemoteProgress = false + providerMetadataOverlay.clear() clearLocalEntries() val payload = WatchProgressStorage.loadPayload(profileId).orEmpty().trim() @@ -347,6 +324,12 @@ object WatchProgressRepository { profileGeneration == generation && ProfileRepository.activeProfileId == profileId + private fun isActiveMetadataTarget( + profileId: Int, + generation: Long, + source: WatchProgressSource, + ): Boolean = isActiveOperation(profileId, generation) && activeSource == source + suspend fun pullFromServer(profileId: Int) { refreshForSource( profileId = profileId, @@ -388,6 +371,7 @@ object WatchProgressRepository { updateActiveSource(source) cancelMetadataResolution(resetProviderHistory = false) + providerMetadataOverlay.clear() activeProgressProvider()?.onActivated() ?: run { hasLoadedNuvioRemoteProgress = false } publish() if (activeProgressProvider()?.providesCompleteMetadata != true) { @@ -931,13 +915,14 @@ object WatchProgressRepository { private fun resolveRemoteMetadata() { val targetProfileId = currentProfileId val targetGeneration = profileGeneration - val missingMetadataEntries = localEntriesSnapshot() + val targetSource = activeSource + val missingMetadataEntries = currentEntries() .filter(WatchProgressEntry::needsRemoteMetadataEnrichment) val entriesToResolve = missingMetadataEntries.continueWatchingEntries( limit = WATCH_PROGRESS_METADATA_RESOLUTION_LIMIT, ) val needsResolution = entriesToResolve - .groupBy { it.parentMetaId to it.contentType } + .groupBy(WatchProgressEntry::metadataKey) if (needsResolution.isEmpty()) return @@ -948,7 +933,7 @@ object WatchProgressRepository { metadataResolutionJob?.cancel() metadataResolutionJob = syncScope.launch(start = CoroutineStart.LAZY) { try { - if (!isActiveOperation(targetProfileId, targetGeneration)) return@launch + if (!isActiveMetadataTarget(targetProfileId, targetGeneration, targetSource)) return@launch AddonRepository.initialize() val providerReadiness = AddonRepository.uiState.value.metadataProviderReadiness() if (providerReadiness.isReady) { @@ -972,30 +957,41 @@ object WatchProgressRepository { repeat(needsResolution.size) { val result = resolutionResults.receive() ensureActive() - if (!isActiveOperation(targetProfileId, targetGeneration)) return@launch + if (!isActiveMetadataTarget(targetProfileId, targetGeneration, targetSource)) return@launch val meta = result.meta if (meta == null) { return@repeat } - var appliedEntries = 0 - for (entry in result.entries) { - val current = localEntry(entry.resolvedProgressKey()) ?: continue - val enriched = enrichWatchProgressEntry(current = current, meta = meta) - if (enriched == current) continue - upsertLocalEntry(enriched) - appliedEntries += 1 + val appliedEntries = if (targetSource.providerId == null) { + var appliedLocalEntries = 0 + for (entry in result.entries) { + val current = localEntry(entry.resolvedProgressKey()) ?: continue + val enriched = enrichWatchProgressEntry(current = current, meta = meta) + if (enriched == current) continue + upsertLocalEntry(enriched) + appliedLocalEntries += 1 + } + appliedLocalEntries + } else if (providerMetadataOverlay.put(targetSource, result.key, meta)) { + result.entries.size + } else { + 0 } if (appliedEntries == 0) return@repeat resolvedEntries += appliedEntries - if (isActiveOperation(targetProfileId, targetGeneration)) { + if (isActiveMetadataTarget(targetProfileId, targetGeneration, targetSource)) { publish() } } resolutionResults.close() - if (resolvedEntries > 0 && isActiveOperation(targetProfileId, targetGeneration)) { + if ( + targetSource.providerId == null && + resolvedEntries > 0 && + isActiveMetadataTarget(targetProfileId, targetGeneration, targetSource) + ) { persist() } } finally { @@ -1013,10 +1009,9 @@ object WatchProgressRepository { } private suspend fun fetchRemoteMetadataGroup( - key: Pair, + key: WatchProgressMetadataKey, entries: List, ): RemoteMetadataResolutionResult { - val (metaId, metaType) = key var meta: MetaDetails? = null for (attempt in 1..WATCH_PROGRESS_METADATA_FETCH_ATTEMPTS) { if (attempt > 1) { @@ -1025,7 +1020,7 @@ object WatchProgressRepository { delay(retryDelayMs) } meta = try { - MetaDetailsRepository.fetch(metaType, metaId) + MetaDetailsRepository.fetch(key.metaType, key.metaId) } catch (error: CancellationException) { throw error } catch (error: Throwable) { @@ -1034,6 +1029,7 @@ object WatchProgressRepository { if (meta != null) break } return RemoteMetadataResolutionResult( + key = key, entries = entries, meta = meta, ) @@ -1465,11 +1461,16 @@ object WatchProgressRepository { ?.snapshot() ?.entries .orEmpty() - return projectWatchProgressSourceEntries( + val projectedEntries = projectWatchProgressSourceEntries( source = activeSource, nuvioEntries = localEntriesSnapshot(), providerEntries = providerEntries, ) + return if (activeSource.providerId == null) { + projectedEntries + } else { + providerMetadataOverlay.project(source = activeSource, entries = projectedEntries) + } } private fun localEntriesSnapshot(): List = diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt index 6fa8e2a8..6440c724 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt @@ -60,7 +60,8 @@ class SimklProjectionsTest { "https://simkl.com/movies/53536/terminator-3-rise-of-the-machines", item.trackingSourceUrl, ) - assertTrue(item.poster.orEmpty().contains("simkl.in/posters/12/poster_w.webp")) + assertTrue(item.poster.orEmpty().contains("simkl.in/posters/12/poster_ca.webp")) + assertFalse(item.poster.orEmpty().contains("_w.webp")) assertEquals(1_700_000_000_000L, item.savedAtEpochMs) assertEquals( setOf(completedDefinition.key), @@ -163,6 +164,7 @@ class SimklProjectionsTest { assertEquals(1_266_000L, entry.lastPositionMs) assertEquals("simkl-playback:12345", entry.progressKey) assertEquals(WatchProgressSourceSimklPlayback, entry.source) + assertTrue(entry.poster.orEmpty().contains("simkl.in/posters/12/poster_ca.webp")) assertFalse(entry.isCompleted) assertEquals(1_714_515_180_250L, entry.lastUpdatedEpochMs) } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressMetadataProjectionTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressMetadataProjectionTest.kt new file mode 100644 index 00000000..8df6898a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressMetadataProjectionTest.kt @@ -0,0 +1,95 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.features.details.MetaDetails +import com.nuvio.app.features.details.MetaVideo +import com.nuvio.app.features.tracking.WatchProgressSource +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class WatchProgressMetadataProjectionTest { + @Test + fun `provider metadata enriches display fields without replacing progress ownership`() { + val raw = entry(source = WatchProgressSourceSimklPlayback) + val overlay = ProviderProgressMetadataOverlay() + overlay.put( + source = WatchProgressSource.SIMKL, + key = raw.metadataKey(), + metadata = metadata(), + ) + + val enriched = overlay.project( + source = WatchProgressSource.SIMKL, + entries = listOf(raw), + ).single() + + assertEquals("Addon title", enriched.title) + assertEquals("addon-poster", enriched.poster) + assertEquals("addon-background", enriched.background) + assertEquals("addon-logo", enriched.logo) + assertEquals("Episode title", enriched.episodeTitle) + assertEquals("episode-thumbnail", enriched.episodeThumbnail) + assertEquals("Episode overview", enriched.pauseDescription) + assertEquals(raw.progressKey, enriched.progressKey) + assertEquals(raw.progressPercent, enriched.progressPercent) + assertEquals(raw.lastPositionMs, enriched.lastPositionMs) + assertEquals(raw.source, enriched.source) + } + + @Test + fun `provider metadata never crosses source boundaries`() { + val raw = entry(source = WatchProgressSourceSimklPlayback) + val overlay = ProviderProgressMetadataOverlay() + overlay.put( + source = WatchProgressSource.SIMKL, + key = raw.metadataKey(), + metadata = metadata(), + ) + + val projected = overlay.project( + source = WatchProgressSource.TRAKT, + entries = listOf(raw), + ).single() + + assertEquals(raw, projected) + assertNull(projected.background) + assertNull(projected.episodeThumbnail) + } + + private fun entry(source: String): WatchProgressEntry = WatchProgressEntry( + contentType = "series", + parentMetaId = "tt1234567", + parentMetaType = "series", + videoId = "tt1234567:1:2", + title = "Simkl title", + poster = "simkl-poster", + seasonNumber = 1, + episodeNumber = 2, + lastPositionMs = 40_000L, + durationMs = 100_000L, + lastUpdatedEpochMs = 500L, + progressPercent = 40f, + source = source, + progressKey = "simkl-playback:10", + ) + + private fun metadata(): MetaDetails = MetaDetails( + id = "tt1234567", + type = "series", + name = "Addon title", + poster = "addon-poster", + background = "addon-background", + logo = "addon-logo", + description = "Show overview", + videos = listOf( + MetaVideo( + id = "tt1234567:1:2", + title = "Episode title", + thumbnail = "episode-thumbnail", + season = 1, + episode = 2, + overview = "Episode overview", + ), + ), + ) +} From 3064c7aa25e5c3d3f4c3ad995464a19d8f83b93a Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:22:52 +0530 Subject: [PATCH 22/59] chore(simkl): add watched marker diagnostics --- .../app/features/details/MetaDetailsScreen.kt | 58 ++++++++ .../simkl/SimklApplicationAdapters.kt | 20 ++- .../app/features/simkl/SimklSyncRepository.kt | 33 ++++- .../features/simkl/SimklWatchDiagnostics.kt | 137 ++++++++++++++++++ .../app/features/watched/WatchedRepository.kt | 89 ++++++++++-- 5 files changed, 321 insertions(+), 16 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklWatchDiagnostics.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index b01c423c..34d43c36 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -70,6 +70,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import androidx.lifecycle.compose.collectAsStateWithLifecycle +import co.touchlab.kermit.Logger import coil3.compose.AsyncImage import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.core.build.TrailerPlaybackMode @@ -125,6 +126,7 @@ import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watched.previousReleasedEpisodesBefore import com.nuvio.app.features.watched.releasedPlayableEpisodes import com.nuvio.app.features.watched.releasedEpisodesForSeason +import com.nuvio.app.features.watched.watchedItemKey import com.nuvio.app.features.watchprogress.CurrentDateProvider import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.WatchProgressRepository @@ -140,6 +142,8 @@ import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource +private val watchedMarkerDiagnosticLog = Logger.withTag("WatchedMarkerDiag") + @Composable @OptIn(ExperimentalSharedTransitionApi::class) fun MetaDetailsScreen( @@ -221,6 +225,60 @@ fun MetaDetailsScreen( var episodeImdbRatings by remember(type, id) { mutableStateOf, Double>>(emptyMap()) } var deferredMetaWorkAllowed by remember(type, id) { mutableStateOf(false) } + LaunchedEffect( + displayedMeta?.id, + displayedMeta?.type, + displayedMeta?.name, + displayedMeta?.videos, + watchedUiState.items, + watchedUiState.isLoaded, + watchedUiState.hasLoadedRemoteItems, + fullyWatchedSeriesKeys, + watchProgressUiState.entries, + trackingSettingsUiState.watchProgressSource, + ) { + val meta = displayedMeta ?: return@LaunchedEffect + val posterKey = watchedItemKey(meta.type, meta.id) + val expectedEpisodeKeys = meta.videos.map { episode -> + watchedItemKey(meta.type, meta.id, episode.season, episode.episode) + } + val matchedEpisodeKeys = expectedEpisodeKeys.filter(watchedUiState.watchedKeys::contains) + val completedProgressMatches = meta.videos.count { episode -> + val videoId = buildPlaybackVideoId( + parentMetaId = meta.id, + seasonNumber = episode.season, + episodeNumber = episode.episode, + fallbackVideoId = episode.id, + ) + progressByVideoId[videoId]?.isEffectivelyCompleted == true + } + val directItemKeys = watchedUiState.items + .asSequence() + .filter { item -> item.id == meta.id } + .take(10) + .joinToString(separator = ",") { item -> + watchedItemKey(item.type, item.id, item.season, item.episode) + } + val titleCandidateKeys = watchedUiState.items + .asSequence() + .filter { item -> item.name.equals(meta.name, ignoreCase = true) } + .take(10) + .joinToString(separator = ",") { item -> + watchedItemKey(item.type, item.id, item.season, item.episode) + } + watchedMarkerDiagnosticLog.i { + "marker state requestedSource=${trackingSettingsUiState.watchProgressSource} " + + "content=${meta.type}:${meta.id} repositoryLoaded=${watchedUiState.isLoaded} " + + "remoteLoaded=${watchedUiState.hasLoadedRemoteItems} repositoryItems=${watchedUiState.items.size} " + + "posterKey=$posterKey posterInWatched=${posterKey in watchedUiState.watchedKeys} " + + "posterInFullyWatched=${posterKey in fullyWatchedSeriesKeys} videos=${meta.videos.size} " + + "episodeMarkerMatches=${matchedEpisodeKeys.size} completedProgressMatches=$completedProgressMatches " + + "directItemKeys=[$directItemKeys] titleCandidateKeys=[$titleCandidateKeys] " + + "expectedEpisodeKeys=[${expectedEpisodeKeys.take(10).joinToString(",")}] " + + "repositoryKeySample=[${watchedUiState.watchedKeys.take(10).joinToString(",")}]" + } + } + val shouldShowComments = commentsEnabled && traktAuthUiState.mode == TraktConnectionMode.CONNECTED && displayedMeta != null && diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt index e4668ba8..7ef348a6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt @@ -27,13 +27,29 @@ object SimklWatchedSyncAdapter : TrackingWatchedProvider { override suspend fun pull(profileId: Int, pageSize: Int): List { if (profileId != ProfileRepository.activeProfileId) return emptyList() SimklSyncRepository.refresh(TrackingRefreshIntent.AUTOMATIC) - return SimklSyncRepository.state.value.snapshot.toSimklWatchedProjection().items + val snapshot = SimklSyncRepository.state.value.snapshot + val projection = snapshot.toSimklWatchedProjection() + SimklWatchDiagnostics.logProjection( + stage = "items-pull", + profileId = profileId, + snapshot = snapshot, + projection = projection, + ) + return projection.items } override suspend fun pullFullyWatchedSeriesKeys(profileId: Int): Set? { if (profileId != ProfileRepository.activeProfileId) return null SimklSyncRepository.refresh(TrackingRefreshIntent.AUTOMATIC) - return SimklSyncRepository.state.value.snapshot.toSimklWatchedProjection().fullyWatchedSeriesKeys + val snapshot = SimklSyncRepository.state.value.snapshot + val projection = snapshot.toSimklWatchedProjection() + SimklWatchDiagnostics.logProjection( + stage = "fully-watched-pull", + profileId = profileId, + snapshot = snapshot, + projection = projection, + ) + return projection.fullyWatchedSeriesKeys } override suspend fun push(profileId: Int, items: Collection) { 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 index 78b0cea6..76765310 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt @@ -57,6 +57,7 @@ object SimklSyncRepository : TrackingProfileStore { } ?: SimklSyncSnapshot() _state.value = SimklSyncUiState(snapshot = snapshot, hasLoaded = true) + SimklWatchDiagnostics.logSnapshot(stage = "cache-load", snapshot = snapshot) } fun refreshAsync(intent: TrackingRefreshIntent) { @@ -66,22 +67,46 @@ object SimklSyncRepository : TrackingProfileStore { suspend fun refresh(intent: TrackingRefreshIntent) { ensureLoaded() val requestedGeneration = profileGeneration + val before = _state.value + SimklWatchDiagnostics.logRefreshRequest( + intent = intent, + profileGeneration = requestedGeneration, + authenticated = SimklAuthRepository.isAuthenticated.value, + snapshot = before.snapshot, + errorMessage = before.errorMessage, + ) refreshGate.runIfNeeded( profileGeneration = requestedGeneration, shouldRun = { val current = _state.value - requestedGeneration == profileGeneration && - SimklAuthRepository.isAuthenticated.value && + val authenticated = SimklAuthRepository.isAuthenticated.value + val nowEpochMs = SimklPlatformClock.nowEpochMs() + val eligible = requestedGeneration == profileGeneration && + authenticated && shouldRunSimklRefresh( intent = intent, lastCheckedAtEpochMs = current.snapshot.lastCheckedAtEpochMs, - nowEpochMs = SimklPlatformClock.nowEpochMs(), + nowEpochMs = nowEpochMs, hasError = current.errorMessage != null, ) + SimklWatchDiagnostics.logRefreshDecision( + intent = intent, + nowEpochMs = nowEpochMs, + lastCheckedAtEpochMs = current.snapshot.lastCheckedAtEpochMs, + authenticated = authenticated, + hasError = current.errorMessage != null, + eligible = eligible, + ) + eligible }, ) { refreshSnapshot(requestedGeneration) } + SimklWatchDiagnostics.logRefreshCompletion( + intent = intent, + before = before, + after = _state.value, + ) } private suspend fun refreshSnapshot(generation: Long) { @@ -111,6 +136,7 @@ object SimklSyncRepository : TrackingProfileStore { snapshot = result, hasLoaded = true, ) + SimklWatchDiagnostics.logSnapshot(stage = "network-commit", snapshot = result) } internal fun commitPlaybackRemoval(sessionIds: Set) { @@ -122,6 +148,7 @@ object SimklSyncRepository : TrackingProfileStore { val updatedSnapshot = current.snapshot.copy(playback = updatedPlayback) SimklSyncStorage.savePayload(json.encodeToString(updatedSnapshot)) _state.value = current.copy(snapshot = updatedSnapshot) + SimklWatchDiagnostics.logSnapshot(stage = "playback-removal", snapshot = updatedSnapshot) } override fun onProfileChanged() { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklWatchDiagnostics.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklWatchDiagnostics.kt new file mode 100644 index 00000000..9460ede7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklWatchDiagnostics.kt @@ -0,0 +1,137 @@ +package com.nuvio.app.features.simkl + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.tracking.TrackingRefreshIntent +import com.nuvio.app.features.watched.watchedItemKey + +internal object SimklWatchDiagnostics { + private const val SAMPLE_LIMIT = 10 + private val log = Logger.withTag("SimklDiag") + + fun logSnapshot( + stage: String, + snapshot: SimklSyncSnapshot, + ) { + val entries = snapshot.entries + val episodeRows = entries.sumOf { entry -> + entry.seasons.sumOf { season -> season.episodes.size } + } + val exactWatchedEpisodeRows = entries.sumOf { entry -> + entry.seasons.sumOf { season -> + season.episodes.count { episode -> !episode.watchedAt.isNullOrBlank() } + } + } + val entrySamples = entries + .asSequence() + .filter { entry -> + entry.status == SimklListStatus.COMPLETED || + entry.watchedEpisodesCount > 0 || + entry.seasons.isNotEmpty() + } + .take(SAMPLE_LIMIT) + .joinToString(separator = ",") { entry -> + val exactRows = entry.seasons.sumOf { season -> + season.episodes.count { episode -> !episode.watchedAt.isNullOrBlank() } + } + "${entry.mediaType.apiValue}:${entry.media?.canonicalContentId() ?: "missing"}:" + + "${entry.status?.apiValue ?: "none"}:" + + "${entry.watchedEpisodesCount}/${entry.totalEpisodesCount}:exact=$exactRows" + } + + log.i { + "snapshot stage=$stage initialized=${snapshot.isInitialized} entries=${entries.size} " + + "movies=${entries.count { it.mediaType == SimklMediaType.MOVIES }} " + + "shows=${entries.count { it.mediaType == SimklMediaType.SHOWS }} " + + "anime=${entries.count { it.mediaType == SimklMediaType.ANIME }} " + + "completedMovies=${entries.count { it.mediaType == SimklMediaType.MOVIES && it.status == SimklListStatus.COMPLETED }} " + + "completedSeries=${entries.count { it.mediaType != SimklMediaType.MOVIES && it.status == SimklListStatus.COMPLETED }} " + + "aggregateWatchedEntries=${entries.count { it.watchedEpisodesCount > 0 }} " + + "aggregateWatchedEpisodes=${entries.sumOf { it.watchedEpisodesCount }} " + + "episodeRows=$episodeRows exactWatchedEpisodeRows=$exactWatchedEpisodeRows " + + "playbackMovies=${snapshot.playback.count { it.mediaType == SimklMediaType.MOVIES }} " + + "playbackEpisodes=${snapshot.playback.count { it.mediaType != SimklMediaType.MOVIES }} " + + "missingCanonicalIds=${entries.count { it.media?.canonicalContentId() == null }} " + + "watermark=${snapshot.watermark} lastChecked=${snapshot.lastCheckedAtEpochMs} " + + "samples=[$entrySamples]" + } + } + + fun logRefreshRequest( + intent: TrackingRefreshIntent, + profileGeneration: Long, + authenticated: Boolean, + snapshot: SimklSyncSnapshot, + errorMessage: String?, + ) { + log.i { + "refresh request intent=$intent generation=$profileGeneration authenticated=$authenticated " + + "initialized=${snapshot.isInitialized} lastChecked=${snapshot.lastCheckedAtEpochMs} " + + "watermark=${snapshot.watermark} hasError=${errorMessage != null}" + } + } + + fun logRefreshDecision( + intent: TrackingRefreshIntent, + nowEpochMs: Long, + lastCheckedAtEpochMs: Long?, + authenticated: Boolean, + hasError: Boolean, + eligible: Boolean, + ) { + log.i { + "refresh decision intent=$intent eligible=$eligible authenticated=$authenticated " + + "hasError=$hasError now=$nowEpochMs lastChecked=$lastCheckedAtEpochMs " + + "elapsedMs=${lastCheckedAtEpochMs?.let { nowEpochMs - it }}" + } + } + + fun logRefreshCompletion( + intent: TrackingRefreshIntent, + before: SimklSyncUiState, + after: SimklSyncUiState, + ) { + log.i { + "refresh completion intent=$intent checkedBefore=${before.snapshot.lastCheckedAtEpochMs} " + + "checkedAfter=${after.snapshot.lastCheckedAtEpochMs} " + + "watermarkBefore=${before.snapshot.watermark} watermarkAfter=${after.snapshot.watermark} " + + "entriesBefore=${before.snapshot.entries.size} entriesAfter=${after.snapshot.entries.size} " + + "playbackBefore=${before.snapshot.playback.size} playbackAfter=${after.snapshot.playback.size} " + + "loading=${after.isLoading} hasLoaded=${after.hasLoaded} error=${after.errorMessage}" + } + } + + fun logProjection( + stage: String, + profileId: Int, + snapshot: SimklSyncSnapshot, + projection: SimklWatchedProjection, + ) { + val items = projection.items + val itemSamples = items + .asSequence() + .take(SAMPLE_LIMIT) + .joinToString(separator = ",") { item -> + watchedItemKey(item.type, item.id, item.season, item.episode) + } + val fullyWatchedSamples = projection.fullyWatchedSeriesKeys + .asSequence() + .take(SAMPLE_LIMIT) + .joinToString(separator = ",") + + log.i { + "watched projection stage=$stage profile=$profileId inputEntries=${snapshot.entries.size} " + + "inputExactEpisodeRows=${snapshot.exactWatchedEpisodeRowCount()} " + + "outputItems=${items.size} outputMovies=${items.count { it.type == "movie" }} " + + "outputEpisodes=${items.count { it.season != null && it.episode != null }} " + + "outputSeriesSummaries=${items.count { it.type == "series" && it.season == null }} " + + "fullyWatchedSeries=${projection.fullyWatchedSeriesKeys.size} " + + "itemKeys=[$itemSamples] fullyWatchedKeys=[$fullyWatchedSamples]" + } + } +} + +private fun SimklSyncSnapshot.exactWatchedEpisodeRowCount(): Int = entries.sumOf { entry -> + entry.seasons.sumOf { season -> + season.episodes.count { episode -> !episode.watchedAt.isNullOrBlank() } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt index 67dfdd47..73324022 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt @@ -98,6 +98,7 @@ object WatchedRepository { private const val watchedItemsDeltaPageSize = 900 private const val watchedDeltaOperationUpsert = "upsert" private const val watchedDeltaOperationDelete = "delete" + private const val watchedDiagnosticSampleLimit = 10 private val accountScopeLock = SynchronizedObject() private var accountScopeJob: Job = SupervisorJob() @@ -231,7 +232,14 @@ object WatchedRepository { } private fun activateEffectiveSource(source: WatchProgressSource): WatchProgressSource { - if (activeSource == source) return source + if (activeSource == source) { + log.i { + "Watched source activation unchanged source=$source generation=$sourceGeneration " + + "items=${itemCountForSource(source)} loaded=${hasLoadedSource(source)}" + } + return source + } + val previousSource = activeSource source.providerId?.let { providerId -> providerItemsByKey.getOrPut(providerId, ::mutableMapOf).clear() providerFullyWatchedSeriesKeys[providerId] = emptySet() @@ -242,6 +250,10 @@ object WatchedRepository { } activeSource = source sourceGeneration += 1L + log.i { + "Watched source activated previous=$previousSource current=$source generation=$sourceGeneration " + + "provider=${source.providerId?.storageId}" + } publish() return source } @@ -312,6 +324,11 @@ object WatchedRepository { val effectiveSource = activateEffectiveSource(source) val operation = newRefreshOperation(profileId) ?: return false + log.i { + "Watched refresh request profile=$profileId requestedSource=$source effectiveSource=$effectiveSource " + + "forceSnapshot=$forceSnapshot profileGeneration=$profileGeneration " + + "sourceGeneration=$sourceGeneration" + } if (effectiveSource.providerId == null) { val authState = AuthRepository.state.value if (authState !is AuthState.Authenticated || authState.isAnonymous) { @@ -325,7 +342,10 @@ object WatchedRepository { return try { effectiveSource.providerId?.let { providerId -> val provider = TrackingProviderRegistry.watchedProvider(providerId) - ?: return false + ?: run { + log.w { "Watched provider missing provider=${providerId.storageId} source=$effectiveSource" } + return false + } pullSnapshotFromAdapter( adapter = provider, operation = operation, @@ -390,7 +410,26 @@ object WatchedRepository { pageSize = watchedItemsPageSize, ) val fullyWatchedSeriesKeys = adapter.pullFullyWatchedSeriesKeys(profileId) - if (!isActiveOperation(operation)) return false + val source = operation.sourceOperation.source + log.i { + "Watched adapter result source=$source provider=${source.providerId?.storageId ?: "nuvio"} " + + "profile=$profileId serverItems=${serverItems.size} " + + "movies=${serverItems.count { it.type == "movie" }} " + + "episodes=${serverItems.count { it.season != null && it.episode != null }} " + + "seriesSummaries=${serverItems.count { it.type != "movie" && it.season == null }} " + + "fullyWatchedSeries=${fullyWatchedSeriesKeys?.size} " + + "itemKeys=[${diagnosticItemKeySample(serverItems)}] " + + "fullyWatchedKeys=[${fullyWatchedSeriesKeys.orEmpty().take(watchedDiagnosticSampleLimit).joinToString(",")}]" + } + if (!isActiveOperation(operation)) { + log.w { + "Watched adapter result discarded source=$source profile=$profileId " + + "operationProfileGeneration=${operation.profileGeneration} currentProfileGeneration=$profileGeneration " + + "operationSourceGeneration=${operation.sourceOperation.generation} currentSourceGeneration=$sourceGeneration " + + "activeSource=$activeSource" + } + return false + } val localAtApply = itemsForSource(operation.sourceOperation.source).values.toList() val mergedSnapshot = mergeWatchedSnapshot( @@ -627,6 +666,10 @@ object WatchedRepository { private fun hasLoadedSource(source: WatchProgressSource): Boolean = source.providerId?.let(loadedProviders::contains) ?: nuvioHasLoaded + private fun itemCountForSource(source: WatchProgressSource): Int = source.providerId + ?.let { providerId -> providerItemsByKey[providerId]?.size ?: 0 } + ?: nuvioItemsByKey.size + fun toggleWatched(item: WatchedItem) { ensureLoaded() val source = activeSource @@ -759,6 +802,12 @@ object WatchedRepository { ) val seriesWatchedItem = meta.toSeriesWatchedItem() val hasSeriesWatchedMarker = isWatched(id = meta.id, type = meta.type) + log.i { + "Watched series reconciliation source=$activeSource content=${meta.type}:${meta.id} " + + "episodes=${meta.videos.size} shouldMarkSeries=$shouldMarkSeriesWatched " + + "hasSeriesMarker=$hasSeriesWatchedMarker " + + "matchingItems=${itemsForSource(activeSource).values.count { it.id == meta.id }}" + } if (shouldMarkSeriesWatched) { if (!hasSeriesWatchedMarker) { markWatched(seriesWatchedItem) @@ -878,19 +927,37 @@ object WatchedRepository { ) .map(WatchedItem::normalizedMarkedAt) .sortedByDescending { it.markedAtEpochMs } - _fullyWatchedSeriesKeys.value = fullyWatchedSeriesKeysForSource(activeSource) + val fullyWatchedSeriesKeys = fullyWatchedSeriesKeysForSource(activeSource) + val watchedKeys = items.mapTo(linkedSetOf()) { + watchedItemKey(it.type, it.id, it.season, it.episode) + } + val isLoaded = hasLoadedSource(activeSource) + val hasLoadedRemoteItems = activeSource.providerId + ?.let(providersLoadedFromRemote::contains) + ?: nuvioHasLoadedRemote + _fullyWatchedSeriesKeys.value = fullyWatchedSeriesKeys _uiState.value = WatchedUiState( items = items, - watchedKeys = items.mapTo(linkedSetOf()) { - watchedItemKey(it.type, it.id, it.season, it.episode) - }, - isLoaded = hasLoadedSource(activeSource), - hasLoadedRemoteItems = activeSource.providerId - ?.let(providersLoadedFromRemote::contains) - ?: nuvioHasLoadedRemote, + watchedKeys = watchedKeys, + isLoaded = isLoaded, + hasLoadedRemoteItems = hasLoadedRemoteItems, ) + log.i { + "Watched publish source=$activeSource provider=${activeSource.providerId?.storageId ?: "nuvio"} " + + "items=${items.size} keys=${watchedKeys.size} fullyWatchedSeries=${fullyWatchedSeriesKeys.size} " + + "isLoaded=$isLoaded hasLoadedRemote=$hasLoadedRemoteItems " + + "itemKeys=[${diagnosticItemKeySample(items)}] " + + "fullyWatchedKeys=[${fullyWatchedSeriesKeys.take(watchedDiagnosticSampleLimit).joinToString(",")}]" + } } + private fun diagnosticItemKeySample(items: Collection): String = items + .asSequence() + .take(watchedDiagnosticSampleLimit) + .joinToString(separator = ",") { item -> + watchedItemKey(item.type, item.id, item.season, item.episode) + } + private fun persistNuvio() { WatchedStorage.savePayload( currentProfileId, From 65817ad73b6660427615fdc02627e9839ccdd626 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:30:45 +0530 Subject: [PATCH 23/59] fix(simkl): separate completion from library status --- .../composeResources/values/strings.xml | 1 + .../commonMain/kotlin/com/nuvio/app/App.kt | 4 ++- .../app/core/ui/TrackingListPickerDialog.kt | 6 ++-- .../app/features/details/MetaDetailsScreen.kt | 4 ++- .../app/features/library/LibraryRepository.kt | 11 ++++-- .../library/TrackingMembershipFeedback.kt | 36 +++++++++++++++++++ .../app/features/simkl/SimklLibraryAdapter.kt | 26 ++++++++++---- .../features/simkl/SimklLibraryProjection.kt | 5 +++ .../features/simkl/SimklMutationRepository.kt | 26 ++++++++++++-- .../tracking/TrackingLibraryMembership.kt | 17 +++++++++ .../app/features/tracking/TrackingReads.kt | 7 +++- .../app/features/tracking/TrackingWrites.kt | 8 ++++- .../trakt/TraktTrackingLibraryProvider.kt | 4 ++- .../simkl/SimklMutationRepositoryTest.kt | 3 +- .../features/simkl/SimklProjectionsTest.kt | 3 ++ .../features/tracking/TrackingReadsTest.kt | 19 ++++++++++ 16 files changed, 161 insertions(+), 19 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/TrackingMembershipFeedback.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingLibraryMembership.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 1083f44c..e6315c3c 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1446,6 +1446,7 @@ Failed to load Trakt lists Failed to update Trakt lists Failed to update tracking lists + %1$s placed this title in %2$s instead of %3$s %1$s • %2$s Update check failed Download failed diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index c6dbd361..d403ff8b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -162,6 +162,7 @@ import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.library.LibrarySection import com.nuvio.app.features.library.LibrarySortOption import com.nuvio.app.features.library.LibrarySourceMode +import com.nuvio.app.features.library.showTrackingMembershipRewriteFeedback import com.nuvio.app.features.library.LibraryScreen import com.nuvio.app.features.library.toLibraryItem import com.nuvio.app.features.library.toMetaPreview @@ -3523,7 +3524,8 @@ private fun MainAppContent( item = item, desiredMembership = pickerMembership, ) - }.onSuccess { + }.onSuccess { result -> + showTrackingMembershipRewriteFeedback(result) showLibraryListPicker = false pickerItem = null pickerError = null diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TrackingListPickerDialog.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TrackingListPickerDialog.kt index 135ee951..f29694de 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TrackingListPickerDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TrackingListPickerDialog.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.nuvio.app.features.tracking.TrackingLibraryTab +import com.nuvio.app.features.tracking.trackingMembershipDestinations import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_cancel import nuvio.composeapp.generated.resources.action_save @@ -48,6 +49,7 @@ fun TrackingListPickerDialog( ) { if (!visible) return val tokens = MaterialTheme.nuvio + val destinations = trackingMembershipDestinations(tabs) BasicAlertDialog( onDismissRequest = onDismiss, @@ -80,7 +82,7 @@ fun TrackingListPickerDialog( ) } - if (isPending && tabs.isEmpty()) { + if (isPending && destinations.isEmpty()) { Box( modifier = Modifier .fillMaxWidth() @@ -108,7 +110,7 @@ fun TrackingListPickerDialog( .height(NuvioTokens.Space.s80 + NuvioTokens.Space.s80 + NuvioTokens.Space.s80 + NuvioTokens.Space.s40), verticalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), ) { - items(items = tabs, key = { it.key }) { tab -> + items(items = destinations, key = { it.key }) { tab -> val selected = membership[tab.key] == true Row( modifier = Modifier diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 34d43c36..314ecf8f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -106,6 +106,7 @@ import com.nuvio.app.features.details.components.SeasonWatchedActionSheet import com.nuvio.app.features.details.components.TrailerPlayerPopup import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.library.LibraryRepository +import com.nuvio.app.features.library.showTrackingMembershipRewriteFeedback import com.nuvio.app.features.library.toLibraryItem import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.streams.StreamAutoPlayPolicy @@ -1299,7 +1300,8 @@ fun MetaDetailsScreen( item = meta.toLibraryItem(savedAtEpochMs = 0L), desiredMembership = pickerMembership, ) - }.onSuccess { + }.onSuccess { result -> + showTrackingMembershipRewriteFeedback(result) showLibraryListPicker = false }.onFailure { error -> pickerError = error.message ?: getString(Res.string.tracking_lists_update_failed) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt index 13814988..7cf30609 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt @@ -12,6 +12,8 @@ import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.tracking.TrackingLibraryProvider import com.nuvio.app.features.tracking.TrackingLibraryTab import com.nuvio.app.features.tracking.TrackingLibraryTabKind +import com.nuvio.app.features.tracking.TrackingMembershipApplyResult +import com.nuvio.app.features.tracking.TrackingMembershipResolution import com.nuvio.app.features.tracking.TrackingProviderRegistry import com.nuvio.app.features.tracking.TrackingRefreshIntent import com.nuvio.app.features.tracking.TrackingSettingsRepository @@ -387,7 +389,10 @@ object LibraryRepository { return libraryMembershipWithLocal(inLocal = inLocal, providerMembership = memberships) } - suspend fun applyMembershipChanges(item: LibraryItem, desiredMembership: Map) { + suspend fun applyMembershipChanges( + item: LibraryItem, + desiredMembership: Map, + ): TrackingMembershipApplyResult { ensureLoaded() val localDesired = desiredMembership[LOCAL_LIBRARY_LIST_KEY] == true val currentlyInLocal = localState.contains(item.id, item.type) @@ -406,6 +411,7 @@ object LibraryRepository { } var firstFailure: Throwable? = null + val resolutions = mutableListOf() TrackingProviderRegistry.connectedLibraryProviders().forEach { provider -> val providerListKeys = provider.snapshot().tabs.mapTo(mutableSetOf(), TrackingLibraryTab::key) val providerMembership = desiredMembership.filterKeys(providerListKeys::contains) @@ -415,7 +421,7 @@ object LibraryRepository { profileId = profileId, item = item, desiredMembership = providerMembership, - ) + )?.let(resolutions::add) } catch (error: CancellationException) { throw error } catch (error: Throwable) { @@ -426,6 +432,7 @@ object LibraryRepository { } publish() firstFailure?.let { throw it } + return TrackingMembershipApplyResult(resolutions = resolutions) } suspend fun removeFromList(item: LibraryItem, listKey: String) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/TrackingMembershipFeedback.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/TrackingMembershipFeedback.kt new file mode 100644 index 00000000..0d83618d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/TrackingMembershipFeedback.kt @@ -0,0 +1,36 @@ +package com.nuvio.app.features.library + +import com.nuvio.app.core.ui.NuvioToastController +import com.nuvio.app.features.tracking.TrackingLibraryTab +import com.nuvio.app.features.tracking.TrackingMembershipApplyResult +import com.nuvio.app.features.tracking.TrackingProviderRegistry +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.tracking_list_status_rewritten +import org.jetbrains.compose.resources.getString + +internal suspend fun showTrackingMembershipRewriteFeedback(result: TrackingMembershipApplyResult) { + val rewrite = result.rewrites.firstOrNull() ?: return + val providerName = TrackingProviderRegistry.authProvider(rewrite.providerId) + ?.descriptor + ?.displayName + ?: rewrite.providerId.storageId.replaceFirstChar { char -> char.titlecase() } + val tabs = TrackingProviderRegistry.libraryProvider(rewrite.providerId)?.snapshot()?.tabs.orEmpty() + val requestedTitle = tabs.statusTitle(rewrite.requestedListKey, providerName) + val resolvedTitle = tabs.statusTitle(rewrite.resolvedListKey, providerName) + NuvioToastController.show( + getString( + Res.string.tracking_list_status_rewritten, + providerName, + resolvedTitle, + requestedTitle, + ), + ) +} + +private fun List.statusTitle( + key: String, + providerName: String, +): String = firstOrNull { tab -> tab.key == key } + ?.title + ?.removePrefix("$providerName ") + ?: key.substringAfterLast(':') diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt index 674a3baa..28ace3bb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt @@ -7,6 +7,7 @@ import com.nuvio.app.features.tracking.TrackingLibraryProvider import com.nuvio.app.features.tracking.TrackingLibrarySnapshot import com.nuvio.app.features.tracking.TrackingLibraryTab import com.nuvio.app.features.tracking.TrackingLibraryTabKind +import com.nuvio.app.features.tracking.TrackingMembershipResolution import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.tracking.TrackingRefreshIntent import kotlinx.coroutines.CoroutineScope @@ -67,8 +68,8 @@ object SimklLibraryRepository { profileId: Int, item: LibraryItem, desiredMembership: Map, - ) { - if (profileId != ProfileRepository.activeProfileId) return + ): TrackingMembershipResolution? { + if (profileId != ProfileRepository.activeProfileId) return null ensureLoaded() val desiredStatuses = simklLibraryStatusDefinitions.filter { definition -> desiredMembership[definition.key] == true @@ -80,7 +81,7 @@ object SimklLibraryRepository { }) { "${desiredStatus?.title} does not support ${item.type}" } val currentStatus = findItem(item.id, item.type)?.listKeys.orEmpty() .firstNotNullOfOrNull(::simklLibraryStatusDefinition) - if (desiredStatus == currentStatus) return + if (desiredStatus == currentStatus) return null val snapshot = SimklSyncRepository.state.value.snapshot val media = snapshot.mediaReference( @@ -103,12 +104,25 @@ object SimklLibraryRepository { SimklMutationRepository.removeFromList(profileId = profileId, items = listOf(media)) } - else -> return + else -> return null } check(result.isComplete) { "Simkl could not match ${result.notFoundCount} of ${result.attemptedCount} library items" } + val resolution = desiredStatus?.let { requested -> + result.resolvedListStatuses + .singleOrNull() + ?.let(::simklLibraryStatusDefinition) + ?.let { resolved -> + TrackingMembershipResolution( + providerId = TrackingProviderId.SIMKL, + requestedListKey = requested.key, + resolvedListKey = resolved.key, + ) + } + } refresh(TrackingRefreshIntent.INVALIDATED) + return resolution } private fun publish(syncState: SimklSyncUiState) { @@ -157,6 +171,7 @@ object SimklTrackingLibraryProvider : TrackingLibraryProvider { }, selectionGroup = SIMKL_STATUS_SELECTION_GROUP, supportedContentTypes = definition.supportedContentTypes, + isMembershipDestination = definition.isMembershipDestination, ) }, hasLoaded = state.hasLoaded, @@ -180,13 +195,12 @@ object SimklTrackingLibraryProvider : TrackingLibraryProvider { profileId: Int, item: LibraryItem, desiredMembership: Map, - ) { + ): TrackingMembershipResolution? = SimklLibraryRepository.applyStatusMembership( profileId = profileId, item = item, desiredMembership = desiredMembership, ) - } override suspend fun toggleDefaultMembership(profileId: Int, item: LibraryItem) { val current = SimklLibraryRepository.statusMembership(item.id, item.type) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt index 497b0f8d..26b46a0c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt @@ -13,6 +13,7 @@ internal data class SimklLibraryStatusDefinition( val title: String, val trackingStatus: TrackingListStatus, val supportedContentTypes: Set, + val isMembershipDestination: Boolean = true, ) internal data class SimklLibraryProjection( @@ -48,6 +49,7 @@ internal val simklLibraryStatusDefinitions = listOf( title = "Simkl Completed", trackingStatus = TrackingListStatus.COMPLETED, supportedContentTypes = setOf("movie", "series"), + isMembershipDestination = false, ), SimklLibraryStatusDefinition( status = SimklListStatus.DROPPED, @@ -87,6 +89,9 @@ internal fun SimklSyncSnapshot.toSimklLibraryProjection(): SimklLibraryProjectio internal fun simklLibraryStatusDefinition(key: String): SimklLibraryStatusDefinition? = simklLibraryStatusDefinitions.firstOrNull { definition -> definition.key == key } +internal fun simklLibraryStatusDefinition(status: TrackingListStatus): SimklLibraryStatusDefinition? = + simklLibraryStatusDefinitions.firstOrNull { definition -> definition.trackingStatus == status } + private fun SimklLibraryEntry.toLibraryItem( listKey: String, lastSyncedAtEpochMs: Long?, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt index ed73aaee..5aebd8d1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt @@ -24,6 +24,7 @@ import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import kotlin.math.round @@ -363,14 +364,33 @@ private fun TrackingExternalIds.toSimklJsonObjectOrNull(): JsonObject? { } private fun SimklApiResponse.toMutationResult(attemptedCount: Int, json: Json): TrackingMutationResult { - val notFound = body + val payload = body .takeIf(String::isNotBlank) - ?.let { payload -> runCatching { json.parseToJsonElement(payload).jsonObject["not_found"]?.jsonObject }.getOrNull() } + ?.let { value -> runCatching { json.parseToJsonElement(value).jsonObject }.getOrNull() } + val notFound = payload + ?.get("not_found") + ?.let { value -> runCatching { value.jsonObject }.getOrNull() } val notFoundCount = notFound ?.values ?.sumOf { value -> (value as? JsonArray)?.size ?: 0 } ?: 0 - return TrackingMutationResult(attemptedCount = attemptedCount, notFoundCount = notFoundCount) + val resolvedListStatuses = payload + ?.get("added") + ?.let { value -> runCatching { value.jsonObject }.getOrNull() } + ?.values + .orEmpty() + .flatMap { value -> (value as? JsonArray).orEmpty() } + .mapNotNull { value -> + val wireValue = runCatching { + value.jsonObject["to"]?.jsonPrimitive?.content + }.getOrNull() + TrackingListStatus.fromWireValue(wireValue) + } + return TrackingMutationResult( + attemptedCount = attemptedCount, + notFoundCount = notFoundCount, + resolvedListStatuses = resolvedListStatuses, + ) } private fun Double.clampAndRoundProgress(): Double = round(coerceIn(0.0, 100.0) * 100.0) / 100.0 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingLibraryMembership.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingLibraryMembership.kt new file mode 100644 index 00000000..9d72aec4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingLibraryMembership.kt @@ -0,0 +1,17 @@ +package com.nuvio.app.features.tracking + +data class TrackingMembershipResolution( + val providerId: TrackingProviderId, + val requestedListKey: String, + val resolvedListKey: String, +) { + val wasRewritten: Boolean + get() = requestedListKey != resolvedListKey +} + +data class TrackingMembershipApplyResult( + val resolutions: List = emptyList(), +) { + val rewrites: List + get() = resolutions.filter(TrackingMembershipResolution::wasRewritten) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt index 373109b3..b0d89b66 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt @@ -20,6 +20,7 @@ data class TrackingLibraryTab( val kind: TrackingLibraryTabKind, val selectionGroup: String? = null, val supportedContentTypes: Set? = null, + val isMembershipDestination: Boolean = true, ) fun TrackingLibraryTab.supportsContentType(contentType: String): Boolean = @@ -27,6 +28,10 @@ fun TrackingLibraryTab.supportsContentType(contentType: String): Boolean = supported.equals(contentType, ignoreCase = true) } +internal fun trackingMembershipDestinations( + tabs: List, +): List = tabs.filter(TrackingLibraryTab::isMembershipDestination) + fun toggleTrackingLibraryMembership( tabs: List, membership: Map, @@ -88,7 +93,7 @@ interface TrackingLibraryProvider { profileId: Int, item: LibraryItem, desiredMembership: Map, - ) + ): TrackingMembershipResolution? suspend fun toggleDefaultMembership(profileId: Int, item: LibraryItem) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingWrites.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingWrites.kt index a3e24681..51a3298b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingWrites.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingWrites.kt @@ -5,7 +5,12 @@ enum class TrackingListStatus(val wireValue: String) { PLAN_TO_WATCH("plantowatch"), ON_HOLD("hold"), COMPLETED("completed"), - DROPPED("dropped"), + DROPPED("dropped"); + + companion object { + internal fun fromWireValue(value: String?): TrackingListStatus? = + entries.firstOrNull { status -> status.wireValue.equals(value, ignoreCase = true) } + } } enum class TrackingScrobbleAction(val wireValue: String) { @@ -27,6 +32,7 @@ data class TrackingScrobbleEvent( data class TrackingMutationResult( val attemptedCount: Int, val notFoundCount: Int = 0, + val resolvedListStatuses: List = emptyList(), ) { val isComplete: Boolean get() = notFoundCount == 0 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt index a8fe68c8..d8b2b1aa 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt @@ -6,6 +6,7 @@ import com.nuvio.app.features.tracking.TrackingLibraryProvider import com.nuvio.app.features.tracking.TrackingLibrarySnapshot import com.nuvio.app.features.tracking.TrackingLibraryTab import com.nuvio.app.features.tracking.TrackingLibraryTabKind +import com.nuvio.app.features.tracking.TrackingMembershipResolution import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.tracking.TrackingRefreshIntent import kotlinx.coroutines.flow.Flow @@ -69,11 +70,12 @@ object TraktTrackingLibraryProvider : TrackingLibraryProvider { profileId: Int, item: LibraryItem, desiredMembership: Map, - ) { + ): TrackingMembershipResolution? { TraktLibraryRepository.applyMembershipChanges( item = item, changes = TraktMembershipChanges(desiredMembership = desiredMembership), ) + return null } override suspend fun toggleDefaultMembership(profileId: Int, item: LibraryItem) = diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt index fcde5750..981b73c4 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt @@ -105,7 +105,7 @@ class SimklMutationRepositoryTest { val engine = RecordingEngine( response( status = 201, - body = """{"added":{"movies":1},"not_found":{"movies":[{"title":"Missing"}],"shows":[]}}""", + body = """{"added":{"movies":[{"to":"completed"}]},"not_found":{"movies":[{"title":"Missing"}],"shows":[]}}""", ), response(status = 409), ) @@ -134,6 +134,7 @@ class SimklMutationRepositoryTest { assertEquals(2, result.attemptedCount) assertEquals(1, result.notFoundCount) + assertEquals(listOf(TrackingListStatus.COMPLETED), result.resolvedListStatuses) assertFalse(result.isComplete) assertEquals(listOf("/sync/add-to-list", "/scrobble/stop"), engine.paths) assertEquals(2, committed) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt index 6440c724..8f66b3ee 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt @@ -71,6 +71,9 @@ class SimklProjectionsTest { setOf(watchingDefinition.key), projection.items.single { candidate -> candidate.id == "tt1520211" }.listKeys, ) + assertTrue(watchingDefinition.isMembershipDestination) + assertTrue(planDefinition.isMembershipDestination) + assertFalse(completedDefinition.isMembershipDestination) } @Test diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingReadsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingReadsTest.kt index 030ce7d5..ae4e17ff 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingReadsTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingReadsTest.kt @@ -53,6 +53,25 @@ class TrackingReadsTest { assertTrue(tab("trakt:watchlist").supportsContentType("movie")) } + @Test + fun `non destination status stays in selection group without appearing in picker`() { + val watching = tab("simkl:watching", selectionGroup = "simkl:status") + val completed = tab("simkl:completed", selectionGroup = "simkl:status") + .copy(isMembershipDestination = false) + val tabs = listOf(watching, completed) + + assertEquals(listOf(watching), trackingMembershipDestinations(tabs)) + + val updated = toggleTrackingLibraryMembership( + tabs = tabs, + membership = mapOf(watching.key to false, completed.key to true), + key = watching.key, + ) + + assertEquals(true, updated[watching.key]) + assertEquals(false, updated[completed.key]) + } + private fun tab( key: String, providerId: TrackingProviderId? = TrackingProviderId.SIMKL, From b0aa543f260767283a11b772992bc5387ee84bdb Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:19:54 +0530 Subject: [PATCH 24/59] fix(simkl): align request retry policies --- .../app/features/simkl/SimklApiClient.kt | 57 +++++++++++------ .../features/simkl/SimklMutationRepository.kt | 4 ++ .../app/features/simkl/SimklApiClientTest.kt | 61 +++++++++++++++++++ .../simkl/SimklMutationRepositoryTest.kt | 25 ++++++++ 4 files changed, 128 insertions(+), 19 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt index a3830660..8768b510 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json @@ -25,6 +26,7 @@ internal enum class SimklHttpMethod { internal enum class SimklRetryPolicy { TRANSIENT_FAILURES, + SYNC_WRITE, NEVER, } @@ -86,12 +88,14 @@ internal class SimklApiClient( null } - var lastTransportFailure: Throwable? = null - val maxRetries = when (request.retryPolicy) { - SimklRetryPolicy.TRANSIENT_FAILURES -> MAX_RETRIES - SimklRetryPolicy.NEVER -> 0 + val maxAttempts = when (request.retryPolicy) { + SimklRetryPolicy.TRANSIENT_FAILURES, + SimklRetryPolicy.SYNC_WRITE, + -> MAX_ATTEMPTS + SimklRetryPolicy.NEVER -> 1 } - for (attempt in 0..maxRetries) { + var syncWriteLockRetried = false + for (attempt in 0 until maxAttempts) { awaitRateLimit(request.method) val response = try { engine.execute( @@ -106,8 +110,7 @@ internal class SimklApiClient( } catch (error: CancellationException) { throw error } catch (error: Throwable) { - lastTransportFailure = error - if (attempt == maxRetries) { + if (attempt == maxAttempts - 1) { throw SimklApiException( status = null, errorCode = "transport_failure", @@ -125,6 +128,18 @@ internal class SimklApiClient( attempt = attempt + 1, ) + if ( + request.retryPolicy == SimklRetryPolicy.SYNC_WRITE && + response.status == 400 && + !syncWriteLockRetried && + response.errorCode(json) == "rate_limit" && + attempt < maxAttempts - 1 + ) { + syncWriteLockRetried = true + sleep(SYNC_WRITE_LOCK_RETRY_DELAY_MS) + continue + } + when (classifySimklResponse(response.status, request.scrobbleStopConflictIsSuccess)) { SimklResponseAction.SUCCESS -> return@withLock response.toApiResponse() SimklResponseAction.SOFT_SUCCESS -> { @@ -136,7 +151,7 @@ internal class SimklApiClient( } SimklResponseAction.FAIL -> throw response.toApiException(json) SimklResponseAction.RETRY -> { - if (attempt == maxRetries) throw response.toApiException(json) + if (attempt == maxAttempts - 1) throw response.toApiException(json) sleep( retryDelayMs( attempt = attempt, @@ -148,12 +163,7 @@ internal class SimklApiClient( } } - throw SimklApiException( - status = null, - errorCode = "transport_failure", - message = "Simkl request failed", - cause = lastTransportFailure, - ) + error("Simkl request loop completed without a response") } private suspend fun awaitRateLimit(method: SimklHttpMethod) { @@ -173,7 +183,8 @@ internal class SimklApiClient( private companion object { const val GET_INTERVAL_MS = 100L const val POST_INTERVAL_MS = 1_000L - const val MAX_RETRIES = 5 + const val MAX_ATTEMPTS = 5 + const val SYNC_WRITE_LOCK_RETRY_DELAY_MS = 3_000L const val RETRY_JITTER_BOUND_MS = 1_000L } } @@ -252,7 +263,8 @@ internal fun retryDelayMs( ?.coerceAtLeast(0L) ?.times(1_000L) ?: 0L - return max(exponentialDelayMs, retryAfterMs) + jitterMs.coerceIn(0L, 1_000L) + return (max(exponentialDelayMs, retryAfterMs) + jitterMs.coerceIn(0L, 1_000L)) + .coerceAtMost(60_000L) } private fun RawHttpResponse.toApiResponse(isSoftSuccess: Boolean = false): SimklApiResponse = @@ -264,18 +276,24 @@ private fun RawHttpResponse.toApiResponse(isSoftSuccess: Boolean = false): Simkl ) private fun RawHttpResponse.toApiException(json: Json): SimklApiException { - val envelope = body.takeIf(String::isNotBlank)?.let { payload -> - runCatching { json.decodeFromString(payload) }.getOrNull() - } + val envelope = errorEnvelope(json) return SimklApiException( status = status, errorCode = envelope?.error, message = envelope?.message?.takeIf(String::isNotBlank) + ?: envelope?.errorDescription?.takeIf(String::isNotBlank) ?: envelope?.error?.takeIf(String::isNotBlank) ?: "Simkl request failed with HTTP $status", ) } +private fun RawHttpResponse.errorCode(json: Json): String? = errorEnvelope(json)?.error + +private fun RawHttpResponse.errorEnvelope(json: Json): SimklErrorEnvelope? = + body.takeIf(String::isNotBlank)?.let { payload -> + runCatching { json.decodeFromString(payload) }.getOrNull() + } + private fun Map.headerValue(name: String): String? = entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) }?.value @@ -284,4 +302,5 @@ private data class SimklErrorEnvelope( val error: String? = null, val code: Int? = null, val message: String? = null, + @SerialName("error_description") val errorDescription: String? = null, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt index 5aebd8d1..45a8b4f5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt @@ -49,6 +49,7 @@ internal class SimklMutationService( method = SimklHttpMethod.POST, path = "/sync/add-to-list", body = buildSimklListMutationBody(candidates, destination, json), + retryPolicy = SimklRetryPolicy.SYNC_WRITE, ), ) onMutationCommitted() @@ -70,6 +71,7 @@ internal class SimklMutationService( method = SimklHttpMethod.POST, path = "/sync/history", body = buildSimklHistoryMutationBody(candidates, json), + retryPolicy = SimklRetryPolicy.SYNC_WRITE, ), ) onMutationCommitted() @@ -84,6 +86,7 @@ internal class SimklMutationService( method = SimklHttpMethod.POST, path = "/sync/history/remove", body = buildSimklHistoryRemovalBody(candidates, json), + retryPolicy = SimklRetryPolicy.SYNC_WRITE, ), ) onMutationCommitted() @@ -100,6 +103,7 @@ internal class SimklMutationService( method = SimklHttpMethod.POST, path = "/scrobble/${action.wireValue}", body = buildSimklScrobbleBody(event, json), + retryPolicy = SimklRetryPolicy.NEVER, scrobbleStopConflictIsSuccess = action == TrackingScrobbleAction.STOP, ), ) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt index 8716bfe6..16189f34 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt @@ -31,6 +31,7 @@ class SimklApiClientTest { assertEquals(8_000L, retryDelayMs(3, null, 0L)) assertEquals(16_000L, retryDelayMs(4, null, 0L)) assertEquals(30_250L, retryDelayMs(0, "30", 250L)) + assertEquals(60_000L, retryDelayMs(0, "120", 1_000L)) } @Test @@ -149,6 +150,66 @@ class SimklApiClientTest { assertEquals(1, deterministicEngine.requests.size) } + @Test + fun `transient failures stop after five total attempts`() = runBlocking { + val engine = RecordingEngine( + response(503), + response(503), + response(503), + response(503), + response(503), + response(200), + ) + val harness = TestHarness(engine) + + assertFailsWith { + harness.client.execute(SimklApiRequest(SimklHttpMethod.GET, "/unavailable")) + } + + assertEquals(5, engine.requests.size) + assertEquals(listOf(1_000L, 2_000L, 4_000L, 8_000L), harness.sleeps) + } + + @Test + fun `sync write lock is retried once and other bad requests are not`() = runBlocking { + val lockedEngine = RecordingEngine( + response(400, """{"error":"rate_limit","error_description":"Another sync is in progress"}"""), + response(200), + ) + val lockedHarness = TestHarness(lockedEngine) + + lockedHarness.client.execute( + SimklApiRequest( + method = SimklHttpMethod.POST, + path = "/sync/history", + body = "{}", + retryPolicy = SimklRetryPolicy.SYNC_WRITE, + ), + ) + + assertEquals(2, lockedEngine.requests.size) + assertEquals(listOf(3_000L), lockedHarness.sleeps) + + val invalidEngine = RecordingEngine( + response(400, """{"error":"wrong_parameter"}"""), + response(200), + ) + val invalidHarness = TestHarness(invalidEngine) + + assertFailsWith { + invalidHarness.client.execute( + SimklApiRequest( + method = SimklHttpMethod.POST, + path = "/sync/history", + body = "{}", + retryPolicy = SimklRetryPolicy.SYNC_WRITE, + ), + ) + } + + assertEquals(1, invalidEngine.requests.size) + } + @Test fun `retry after and unauthorized handling are applied once`() = runBlocking { val retryEngine = RecordingEngine( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt index 981b73c4..4ebad69c 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt @@ -16,6 +16,7 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -140,6 +141,30 @@ class SimklMutationRepositoryTest { assertEquals(2, committed) } + @Test + fun `service leaves failed scrobble retry to the next player event`() = runBlocking { + val engine = RecordingEngine(response(status = 503), response(status = 200)) + val service = SimklMutationService( + client = SimklApiClient( + engine = engine, + accessToken = { "token" }, + onUnauthorized = {}, + nowEpochMs = { 0L }, + sleep = {}, + retryJitterMs = { 0L }, + ), + ) + + assertFailsWith { + service.scrobble( + action = TrackingScrobbleAction.PAUSE, + event = TrackingScrobbleEvent(movie(), progressPercent = 45.0), + ) + } + + assertEquals(listOf("/scrobble/pause"), engine.paths) + } + private fun String.asObject() = json.parseToJsonElement(this).jsonObject private fun movie() = TrackingMediaReference( From 942a111eec6ea50f24814c61cb8fb4f5ed3cca83 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:23:12 +0530 Subject: [PATCH 25/59] fix(simkl): parse resolved history mutations --- .../features/simkl/SimklMutationRepository.kt | 63 +++++++++++++++---- .../app/features/tracking/TrackingWrites.kt | 11 +++- .../simkl/SimklMutationRepositoryTest.kt | 52 +++++++++++++++ 3 files changed, 114 insertions(+), 12 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt index 45a8b4f5..013bcace 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt @@ -10,6 +10,7 @@ import com.nuvio.app.features.tracking.TrackingListWriter import com.nuvio.app.features.tracking.TrackingMediaKind import com.nuvio.app.features.tracking.TrackingMediaReference import com.nuvio.app.features.tracking.TrackingMutationResult +import com.nuvio.app.features.tracking.TrackingMutationResolution import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.tracking.TrackingProviderRegistry import com.nuvio.app.features.tracking.TrackingRefreshIntent @@ -378,25 +379,65 @@ private fun SimklApiResponse.toMutationResult(attemptedCount: Int, json: Json): ?.values ?.sumOf { value -> (value as? JsonArray)?.size ?: 0 } ?: 0 - val resolvedListStatuses = payload + val added = payload ?.get("added") ?.let { value -> runCatching { value.jsonObject }.getOrNull() } - ?.values - .orEmpty() - .flatMap { value -> (value as? JsonArray).orEmpty() } - .mapNotNull { value -> - val wireValue = runCatching { - value.jsonObject["to"]?.jsonPrimitive?.content - }.getOrNull() - TrackingListStatus.fromWireValue(wireValue) - } + val resolutions = added?.toMutationResolutions().orEmpty() return TrackingMutationResult( attemptedCount = attemptedCount, notFoundCount = notFoundCount, - resolvedListStatuses = resolvedListStatuses, + resolutions = resolutions, ) } +private fun JsonObject.toMutationResolutions(): List { + val historyResolutions = (get("statuses") as? JsonArray) + .orEmpty() + .mapNotNull { element -> + val response = runCatching { element.jsonObject["response"]?.jsonObject }.getOrNull() + ?: return@mapNotNull null + response.toMutationResolution(statusKey = "status") + } + if (historyResolutions.isNotEmpty()) return historyResolutions + + return entries.flatMap { (bucket, value) -> + (value as? JsonArray).orEmpty().mapNotNull { element -> + runCatching { element.jsonObject }.getOrNull() + ?.toMutationResolution(statusKey = "to", fallbackKind = bucket.toTrackingMediaKind()) + } + } +} + +private fun JsonObject.toMutationResolution( + statusKey: String, + fallbackKind: TrackingMediaKind? = null, +): TrackingMutationResolution? { + val status = TrackingListStatus.fromWireValue(stringValue(statusKey)) + val mediaKind = stringValue("simkl_type")?.toTrackingMediaKind() + ?: stringValue("type")?.toTrackingMediaKind() + ?: fallbackKind + val providerSubtype = stringValue("anime_type") + if (status == null && mediaKind == null && providerSubtype == null) return null + return TrackingMutationResolution( + listStatus = status, + mediaKind = mediaKind, + providerSubtype = providerSubtype, + ) +} + +private fun JsonObject.stringValue(key: String): String? = + runCatching { get(key)?.jsonPrimitive?.content } + .getOrNull() + ?.trim() + ?.takeIf(String::isNotEmpty) + +private fun String.toTrackingMediaKind(): TrackingMediaKind? = when (lowercase()) { + "movie", "movies" -> TrackingMediaKind.MOVIE + "anime" -> TrackingMediaKind.ANIME + "tv", "show", "shows" -> TrackingMediaKind.SHOW + else -> null +} + private fun Double.clampAndRoundProgress(): Double = round(coerceIn(0.0, 100.0) * 100.0) / 100.0 private fun String?.nonBlankOrNull(): String? = this?.trim()?.takeIf(String::isNotEmpty) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingWrites.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingWrites.kt index 51a3298b..b74afa36 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingWrites.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingWrites.kt @@ -32,12 +32,21 @@ data class TrackingScrobbleEvent( data class TrackingMutationResult( val attemptedCount: Int, val notFoundCount: Int = 0, - val resolvedListStatuses: List = emptyList(), + val resolutions: List = emptyList(), ) { + val resolvedListStatuses: List + get() = resolutions.mapNotNull(TrackingMutationResolution::listStatus) + val isComplete: Boolean get() = notFoundCount == 0 } +data class TrackingMutationResolution( + val listStatus: TrackingListStatus? = null, + val mediaKind: TrackingMediaKind? = null, + val providerSubtype: String? = null, +) + interface TrackingListWriter { val providerId: TrackingProviderId diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt index 4ebad69c..81228c95 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt @@ -141,6 +141,58 @@ class SimklMutationRepositoryTest { assertEquals(2, committed) } + @Test + fun `history response exposes resolved status catalog and anime subtype`() = runBlocking { + val engine = RecordingEngine( + response( + status = 201, + body = """ + { + "added": { + "movies": 0, + "shows": 1, + "episodes": 1, + "statuses": [ + { + "request": {"title":"Attack on Titan","type":"show"}, + "response": { + "status":"watching", + "simkl_type":"anime", + "anime_type":"tv" + } + } + ] + }, + "not_found": {"movies":[],"shows":[],"episodes":[]} + } + """.trimIndent(), + ), + ) + val service = SimklMutationService( + client = SimklApiClient( + engine = engine, + accessToken = { "token" }, + onUnauthorized = {}, + nowEpochMs = { 0L }, + sleep = {}, + retryJitterMs = { 0L }, + ), + ) + + val result = service.addToHistory( + listOf( + TrackingHistoryItem( + media = anime(TrackingEpisode(number = 1)), + watchedAtEpochMs = 1_700_000_000_000L, + ), + ), + ) + + assertEquals(listOf(TrackingListStatus.WATCHING), result.resolvedListStatuses) + assertEquals(TrackingMediaKind.ANIME, result.resolutions.single().mediaKind) + assertEquals("tv", result.resolutions.single().providerSubtype) + } + @Test fun `service leaves failed scrobble retry to the next player event`() = runBlocking { val engine = RecordingEngine(response(status = 503), response(status = 200)) From e020b9700a02a585ea3e4f1ccd10345650140e5e Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:26:03 +0530 Subject: [PATCH 26/59] fix(simkl): map anime episode coordinates --- .../features/simkl/SimklMutationRepository.kt | 14 ++++- .../app/features/simkl/SimklProjections.kt | 4 +- .../simkl/SimklMutationRepositoryTest.kt | 61 +++++++++++++++++-- .../features/simkl/SimklProjectionsTest.kt | 30 +++++++++ 4 files changed, 100 insertions(+), 9 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt index 013bcace..950b304d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt @@ -231,11 +231,17 @@ internal fun buildSimklScrobbleBody( json: Json = SimklMutationJson, ): String { val media = event.media.toScrobbleMediaDto() + val usesTvStyleAnimeCoordinates = event.media.kind == TrackingMediaKind.ANIME && + event.media.episode?.season != null val request = SimklScrobbleRequestDto( progress = event.progressPercent.clampAndRoundProgress(), movie = media.takeIf { event.media.kind == TrackingMediaKind.MOVIE }, - show = media.takeIf { event.media.kind == TrackingMediaKind.SHOW }, - anime = media.takeIf { event.media.kind == TrackingMediaKind.ANIME }, + show = media.takeIf { + event.media.kind == TrackingMediaKind.SHOW || usesTvStyleAnimeCoordinates + }, + anime = media.takeIf { + event.media.kind == TrackingMediaKind.ANIME && !usesTvStyleAnimeCoordinates + }, episode = event.media.episode?.toEpisodeDto( includeSeason = true, includeWatchedAt = false, @@ -316,6 +322,7 @@ private fun buildShowHistoryItem( includeWatchedAt = includeWatchedAt, episodes = flatEpisodes, seasons = seasons, + useTvdbAnimeSeasons = first.media.kind == TrackingMediaKind.ANIME && seasons.isNotEmpty(), ) } @@ -325,6 +332,7 @@ private fun TrackingMediaReference.toHistoryItemDto( status: String? = null, episodes: List = emptyList(), seasons: List = emptyList(), + useTvdbAnimeSeasons: Boolean = false, ): SimklHistoryItemDto = SimklHistoryItemDto( title = title.nonBlankOrNull(), year = year, @@ -333,6 +341,7 @@ private fun TrackingMediaReference.toHistoryItemDto( status = status, episodes = episodes, seasons = seasons, + useTvdbAnimeSeasons = useTvdbAnimeSeasons, ) private fun TrackingMediaReference.toScrobbleMediaDto(): SimklScrobbleMediaDto = @@ -508,6 +517,7 @@ private data class SimklHistoryItemDto( val status: String? = null, val episodes: List = emptyList(), val seasons: List = emptyList(), + @SerialName("use_tvdb_anime_seasons") val useTvdbAnimeSeasons: Boolean = false, ) @Serializable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt index f4a8ea3b..885350f0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt @@ -48,9 +48,9 @@ internal fun SimklSyncSnapshot.toSimklWatchedProjection(): SimklWatchedProjectio var hasEpisodeHistory = false entry.seasons.forEach seasonLoop@{ season -> - val seasonNumber = season.number ?: return@seasonLoop season.episodes.forEach episodeLoop@{ episode -> - val episodeNumber = episode.number ?: return@episodeLoop + val seasonNumber = episode.tvdb?.season ?: season.number ?: return@episodeLoop + val episodeNumber = episode.tvdb?.episode ?: episode.number ?: return@episodeLoop val watchedAt = parseSimklUtcEpochMs(episode.watchedAt) ?: return@episodeLoop hasEpisodeHistory = true watchedItems += WatchedItem( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt index 81228c95..37f7a895 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt @@ -67,6 +67,40 @@ class SimklMutationRepositoryTest { assertEquals("2023-11-14T22:13:20Z", movieItem.getValue("watched_at").jsonPrimitive.content) } + @Test + fun `anime history uses tvdb mapping flag only for seasonal coordinates`() { + val seasonalBody = buildSimklHistoryMutationBody( + listOf( + TrackingHistoryItem( + anime(TrackingEpisode(season = 2, number = 4)), + watchedAtEpochMs = 1_700_000_000_000L, + ), + ), + ).asObject() + val seasonalAnime = seasonalBody.getValue("shows").jsonArray.single().jsonObject + assertTrue(seasonalAnime.getValue("use_tvdb_anime_seasons").jsonPrimitive.content.toBoolean()) + + val flatBody = buildSimklHistoryMutationBody( + listOf( + TrackingHistoryItem( + anime(TrackingEpisode(number = 4)), + watchedAtEpochMs = 1_700_000_000_000L, + ), + ), + ).asObject() + assertNull(flatBody.getValue("shows").jsonArray.single().jsonObject["use_tvdb_anime_seasons"]) + + val showBody = buildSimklHistoryMutationBody( + listOf( + TrackingHistoryItem( + show(TrackingEpisode(season = 2, number = 4)), + watchedAtEpochMs = 1_700_000_000_000L, + ), + ), + ).asObject() + assertNull(showBody.getValue("shows").jsonArray.single().jsonObject["use_tvdb_anime_seasons"]) + } + @Test fun `anime removal uses shows array and contains no response-only or watch fields`() { val body = buildSimklHistoryRemovalBody( @@ -78,6 +112,12 @@ class SimklMutationRepositoryTest { assertNull(item["watched_at"]) assertNull(item["status"]) assertEquals(4, item.getValue("episodes").jsonArray.single().jsonObject.getValue("number").jsonPrimitive.content.toInt()) + + val seasonalBody = buildSimklHistoryRemovalBody( + listOf(anime(TrackingEpisode(season = 2, number = 4))), + ).asObject() + val seasonalItem = seasonalBody.getValue("shows").jsonArray.single().jsonObject + assertTrue(seasonalItem.getValue("use_tvdb_anime_seasons").jsonPrimitive.content.toBoolean()) } @Test @@ -89,16 +129,27 @@ class SimklMutationRepositoryTest { assertTrue("movie" in movieBody) assertFalse("show" in movieBody) - val animeBody = buildSimklScrobbleBody( + val tvStyleAnimeBody = buildSimklScrobbleBody( TrackingScrobbleEvent( anime(TrackingEpisode(season = 2, number = 4)), progressPercent = 42.236, ), ).asObject() - assertEquals(42.24, animeBody.getValue("progress").jsonPrimitive.content.toDouble()) - assertTrue("anime" in animeBody) - assertEquals(2, animeBody.getValue("episode").jsonObject.getValue("season").jsonPrimitive.content.toInt()) - assertEquals(4, animeBody.getValue("episode").jsonObject.getValue("number").jsonPrimitive.content.toInt()) + assertEquals(42.24, tvStyleAnimeBody.getValue("progress").jsonPrimitive.content.toDouble()) + assertTrue("show" in tvStyleAnimeBody) + assertFalse("anime" in tvStyleAnimeBody) + assertEquals(2, tvStyleAnimeBody.getValue("episode").jsonObject.getValue("season").jsonPrimitive.content.toInt()) + assertEquals(4, tvStyleAnimeBody.getValue("episode").jsonObject.getValue("number").jsonPrimitive.content.toInt()) + + val nativeAnimeBody = buildSimklScrobbleBody( + TrackingScrobbleEvent( + anime(TrackingEpisode(number = 4)), + progressPercent = 42.236, + ), + ).asObject() + assertTrue("anime" in nativeAnimeBody) + assertFalse("show" in nativeAnimeBody) + assertNull(nativeAnimeBody.getValue("episode").jsonObject["season"]) } @Test diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt index 8f66b3ee..af2db560 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt @@ -142,6 +142,36 @@ class SimklProjectionsTest { assertTrue(projection.fullyWatchedSeriesKeys.isEmpty()) } + @Test + fun `anime watched projection prefers mapped tvdb coordinates`() { + val anime = entry( + type = SimklMediaType.ANIME, + status = SimklListStatus.WATCHING, + id = 439744, + imdb = "tt2560140", + seasons = listOf( + SimklSeason( + number = 1, + episodes = listOf( + SimklEpisode( + number = 4, + watchedAt = "2023-11-14T23:13:20Z", + tvdb = SimklEpisodeMapping(season = 2, episode = 4), + ), + ), + ), + ), + ) + + val watched = SimklSyncSnapshot(entries = listOf(anime)) + .toSimklWatchedProjection() + .items + .single() + + assertEquals(2, watched.season) + assertEquals(4, watched.episode) + } + @Test fun `playback projection preserves Simkl session identity and percentage`() { val session = SimklPlaybackSession( From 9798482fffd828a2c871c2de63d36b70b265e371 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:29:17 +0530 Subject: [PATCH 27/59] fix(simkl): refresh settings from activity changes --- .../app/features/simkl/SimklApiClient.kt | 2 +- .../app/features/simkl/SimklAuthModels.kt | 20 +++++++ .../app/features/simkl/SimklAuthRepository.kt | 27 +++++++--- .../app/features/simkl/SimklSyncRepository.kt | 2 + .../app/features/simkl/SimklApiClientTest.kt | 17 ++++++ .../features/simkl/SimklSettingsPolicyTest.kt | 53 +++++++++++++++++++ 6 files changed, 114 insertions(+), 7 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSettingsPolicyTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt index 8768b510..9e775b9b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt @@ -103,7 +103,7 @@ internal class SimklApiClient( url = buildSimklApiUrl(request.path, request.query), headers = simklRequestHeaders( accessToken = token, - contentTypeJson = request.body.isNotEmpty(), + contentTypeJson = request.method == SimklHttpMethod.POST, ), body = request.body, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthModels.kt index 379f6d04..4d1dab0f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthModels.kt @@ -33,6 +33,8 @@ data class SimklAuthUiState( internal data class SimklStoredAuthState( val username: String? = null, val accountId: Long? = null, + val hasFetchedUserSettings: Boolean = false, + val settingsActivityWatermark: String? = null, val tokenExpiresAtEpochMs: Long? = null, val pendingAuthorizationState: String? = null, val pendingAuthorizationStartedAtEpochMs: Long? = null, @@ -41,6 +43,24 @@ internal data class SimklStoredAuthState( get() = !pendingAuthorizationState.isNullOrBlank() } +internal enum class SimklSettingsRefreshAction { + NONE, + RECORD_WATERMARK, + FETCH, +} + +internal fun simklSettingsRefreshAction( + state: SimklStoredAuthState, + activityWatermark: String?, +): SimklSettingsRefreshAction = when { + activityWatermark.isNullOrBlank() -> SimklSettingsRefreshAction.NONE + activityWatermark == state.settingsActivityWatermark -> SimklSettingsRefreshAction.NONE + state.settingsActivityWatermark == null && state.hasFetchedUserSettings -> { + SimklSettingsRefreshAction.RECORD_WATERMARK + } + else -> SimklSettingsRefreshAction.FETCH +} + internal sealed interface SimklAuthCallback { data class AuthorizationCode( val code: String, 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 c7da5709..6feed1dc 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 @@ -187,7 +187,21 @@ object SimklAuthRepository : TrackingAuthProvider { suspend fun refreshUserSettings(): String? { authorizedAccessToken() ?: return null - return fetchAndStoreUserSettings() + return if (fetchAndStoreUserSettings()) storedState.username else null + } + + internal suspend fun synchronizeUserSettings(activityWatermark: String?) { + authorizedAccessToken() ?: return + when (simklSettingsRefreshAction(storedState, activityWatermark)) { + SimklSettingsRefreshAction.NONE -> Unit + SimklSettingsRefreshAction.RECORD_WATERMARK -> { + storedState = storedState.copy(settingsActivityWatermark = activityWatermark) + persistMetadata() + } + SimklSettingsRefreshAction.FETCH -> { + fetchAndStoreUserSettings(activityWatermark) + } + } } private suspend fun completeAuthorization(callback: SimklAuthCallback.AuthorizationCode) = @@ -261,30 +275,31 @@ object SimklAuthRepository : TrackingAuthProvider { SimklSyncRepository.refreshAsync(TrackingRefreshIntent.INVALIDATED) } - private suspend fun fetchAndStoreUserSettings(): String? { + private suspend fun fetchAndStoreUserSettings(activityWatermark: String? = null): Boolean { val response = try { SimklApi.client.execute( SimklApiRequest( method = SimklHttpMethod.POST, path = "/users/settings", - body = "{}", ), ) } catch (error: CancellationException) { throw error } catch (error: Throwable) { log.w { "Failed to fetch Simkl user settings: ${error.message}" } - return null + return false } val settings = runCatching { json.decodeFromString(response.body) } - .getOrNull() ?: return null + .getOrNull() ?: return false storedState = storedState.copy( username = settings.user?.name, accountId = settings.account?.id, + hasFetchedUserSettings = true, + settingsActivityWatermark = activityWatermark ?: storedState.settingsActivityWatermark, ) persistMetadata() publish(error = null) - return storedState.username + return true } private fun loadFromDisk() { 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 index 76765310..19494aa1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt @@ -130,6 +130,8 @@ object SimklSyncRepository : TrackingProfileStore { return } + if (generation != profileGeneration || profileId != ProfileRepository.activeProfileId) return + SimklAuthRepository.synchronizeUserSettings(result.activities?.settings?.all) if (generation != profileGeneration || profileId != ProfileRepository.activeProfileId) return SimklSyncStorage.savePayload(json.encodeToString(result)) _state.value = SimklSyncUiState( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt index 16189f34..2aa849ca 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt @@ -69,6 +69,23 @@ class SimklApiClientTest { assertTrue(request.headers.getValue("User-Agent").contains('/')) } + @Test + fun `bodyless post sends json content type without inventing a payload`() = runBlocking { + val engine = RecordingEngine(response(200)) + val harness = TestHarness(engine) + + harness.client.execute( + SimklApiRequest( + method = SimklHttpMethod.POST, + path = "/users/settings", + ), + ) + + val request = engine.requests.single() + assertEquals("", request.body) + assertEquals("application/json", request.headers["Content-Type"]) + } + @Test fun `single use unauthenticated posts keep metadata and never retry`() = runBlocking { val engine = RecordingEngine(response(503), response(200)) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSettingsPolicyTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSettingsPolicyTest.kt new file mode 100644 index 00000000..2a891162 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSettingsPolicyTest.kt @@ -0,0 +1,53 @@ +package com.nuvio.app.features.simkl + +import kotlin.test.Test +import kotlin.test.assertEquals + +class SimklSettingsPolicyTest { + @Test + fun `missing activity watermark does not fetch settings`() { + assertEquals( + SimklSettingsRefreshAction.NONE, + simklSettingsRefreshAction(SimklStoredAuthState(), null), + ) + } + + @Test + fun `first activity watermark is recorded after sign in settings fetch`() { + assertEquals( + SimklSettingsRefreshAction.RECORD_WATERMARK, + simklSettingsRefreshAction( + state = SimklStoredAuthState(hasFetchedUserSettings = true), + activityWatermark = "2026-07-22T08:48:07Z", + ), + ) + } + + @Test + fun `changed activity watermark fetches settings and matching watermark skips`() { + val state = SimklStoredAuthState( + hasFetchedUserSettings = true, + settingsActivityWatermark = "2026-07-22T08:48:07Z", + ) + + assertEquals( + SimklSettingsRefreshAction.NONE, + simklSettingsRefreshAction(state, "2026-07-22T08:48:07Z"), + ) + assertEquals( + SimklSettingsRefreshAction.FETCH, + simklSettingsRefreshAction(state, "2026-07-22T09:12:30Z"), + ) + } + + @Test + fun `legacy auth state fetches once before recording a watermark`() { + assertEquals( + SimklSettingsRefreshAction.FETCH, + simklSettingsRefreshAction( + state = SimklStoredAuthState(username = "Nuvio User"), + activityWatermark = "2026-07-22T08:48:07Z", + ), + ) + } +} From dbf2308c9f3210ce69ebb8edd9f648a35539f468 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:34:57 +0530 Subject: [PATCH 28/59] fix(tracking): preserve provider attribution --- .../app/features/details/MetaDetailsScreen.kt | 24 +++++--- .../app/features/library/LibraryModels.kt | 12 ++-- .../features/simkl/SimklLibraryProjection.kt | 3 +- .../app/features/simkl/SimklProjections.kt | 18 ++++++ .../features/tracking/TrackingAttribution.kt | 35 +++++++++++ .../app/features/watched/WatchedModels.kt | 9 ++- .../watchprogress/WatchProgressModels.kt | 9 ++- .../features/simkl/SimklProjectionsTest.kt | 8 ++- .../tracking/TrackingAttributionTest.kt | 59 +++++++++++++++++++ 9 files changed, 162 insertions(+), 15 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingAttribution.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingAttributionTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 314ecf8f..41f45319 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -121,6 +121,7 @@ import com.nuvio.app.features.tracking.TrackingLibraryTab import com.nuvio.app.features.tracking.toggleTrackingLibraryMembership import com.nuvio.app.features.tracking.TrackingSettingsRepository import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.resolveTrackingAttribution import com.nuvio.app.features.trailer.TrailerPlaybackResolver import com.nuvio.app.features.trailer.TrailerPlaybackSource import com.nuvio.app.features.watched.WatchedRepository @@ -457,13 +458,22 @@ fun MetaDetailsScreen( ) { LibraryRepository.isSaved(meta.id, meta.type) } - val simklSourceUrl = remember(libraryUiState.items, meta.id) { - libraryUiState.items - .firstOrNull { item -> - item.id == meta.id && - item.trackingProviderId == TrackingProviderId.SIMKL.storageId - } - ?.trackingSourceUrl + val simklSourceUrl = remember( + libraryUiState.items, + watchProgressUiState.entries, + watchedUiState.items, + meta.id, + ) { + resolveTrackingAttribution( + contentId = meta.id, + providerId = TrackingProviderId.SIMKL, + items = sequence { + yieldAll(libraryUiState.items) + yieldAll(watchProgressUiState.entries) + yieldAll(watchedUiState.items) + }, + ) + ?.sourceUrl ?.takeIf { url -> url.startsWith("https://simkl.com/") } } val isWatched = remember(watchedUiState.watchedKeys, fullyWatchedSeriesKeys, metaPreview) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt index 254c7ff5..5f4da785 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt @@ -3,6 +3,7 @@ package com.nuvio.app.features.library import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.PosterShape +import com.nuvio.app.features.tracking.TrackingAttributedItem import kotlinx.serialization.Serializable @Serializable @@ -24,11 +25,14 @@ data class LibraryItem( val imdbId: String? = null, val tmdbId: Int? = null, val traktId: Int? = null, - val trackingProviderId: String? = null, - val trackingProviderItemId: String? = null, - val trackingSourceUrl: String? = null, + override val trackingProviderId: String? = null, + override val trackingProviderItemId: String? = null, + override val trackingSourceUrl: String? = null, val savedAtEpochMs: Long, -) +) : TrackingAttributedItem { + override val trackingContentId: String + get() = id +} data class LibrarySection( val type: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt index 26b46a0c..237ba5e2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt @@ -4,6 +4,7 @@ import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.library.LibraryItem import com.nuvio.app.features.library.LibrarySection import com.nuvio.app.features.tracking.TrackingListStatus +import com.nuvio.app.features.tracking.TrackingProviderId internal const val SIMKL_STATUS_SELECTION_GROUP = "simkl:status" @@ -109,7 +110,7 @@ private fun SimklLibraryEntry.toLibraryItem( listKeys = setOf(listKey), imdbId = media.ids.idValue("imdb"), tmdbId = media.ids.idValue("tmdb")?.toIntOrNull(), - trackingProviderId = "simkl", + trackingProviderId = TrackingProviderId.SIMKL.storageId, trackingProviderItemId = simklId?.let { "simkl:$it" }, trackingSourceUrl = buildSimklSourceUrl(mediaType, media), savedAtEpochMs = parseSimklUtcEpochMs(addedToWatchlistAt) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt index 885350f0..00b8c7e9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt @@ -4,6 +4,7 @@ import com.nuvio.app.features.tracking.TrackingEpisode import com.nuvio.app.features.tracking.TrackingExternalIds import com.nuvio.app.features.tracking.TrackingMediaKind import com.nuvio.app.features.tracking.TrackingMediaReference +import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.tracking.extractTrackingYear import com.nuvio.app.features.tracking.parseTrackingExternalIds import com.nuvio.app.features.tracking.trackingMediaKind @@ -28,6 +29,8 @@ internal fun SimklSyncSnapshot.toSimklWatchedProjection(): SimklWatchedProjectio val contentType = if (entry.mediaType == SimklMediaType.MOVIES) "movie" else "series" val title = media.title?.takeIf(String::isNotBlank) ?: contentId val poster = simklPosterUrl(media.poster) + val trackingProviderItemId = media.simklTrackingProviderItemId() + val trackingSourceUrl = buildSimklSourceUrl(entry.mediaType, media) val lastWatchedAt = parseSimklUtcEpochMs(entry.lastWatchedAt) ?: parseSimklUtcEpochMs(entry.addedToWatchlistAt) ?: 0L @@ -40,6 +43,9 @@ internal fun SimklSyncSnapshot.toSimklWatchedProjection(): SimklWatchedProjectio name = title, poster = poster, releaseInfo = media.year?.toString(), + trackingProviderId = TrackingProviderId.SIMKL.storageId, + trackingProviderItemId = trackingProviderItemId, + trackingSourceUrl = trackingSourceUrl, markedAtEpochMs = lastWatchedAt, ) } @@ -61,6 +67,9 @@ internal fun SimklSyncSnapshot.toSimklWatchedProjection(): SimklWatchedProjectio releaseInfo = media.year?.toString(), season = seasonNumber, episode = episodeNumber, + trackingProviderId = TrackingProviderId.SIMKL.storageId, + trackingProviderItemId = trackingProviderItemId, + trackingSourceUrl = trackingSourceUrl, markedAtEpochMs = watchedAt, ) } @@ -75,6 +84,9 @@ internal fun SimklSyncSnapshot.toSimklWatchedProjection(): SimklWatchedProjectio name = title, poster = poster, releaseInfo = media.year?.toString(), + trackingProviderId = TrackingProviderId.SIMKL.storageId, + trackingProviderItemId = trackingProviderItemId, + trackingSourceUrl = trackingSourceUrl, markedAtEpochMs = lastWatchedAt, ) } @@ -252,11 +264,17 @@ private fun SimklPlaybackSession.toWatchProgressEntry(): WatchProgressEntry? { isCompleted = normalizedProgress >= SIMKL_WATCHED_THRESHOLD_PERCENT, progressPercent = normalizedProgress.toFloat(), source = WatchProgressSourceSimklPlayback, + trackingProviderId = TrackingProviderId.SIMKL.storageId, + trackingProviderItemId = media.simklTrackingProviderItemId(), + trackingSourceUrl = buildSimklSourceUrl(mediaType, media), progressKey = id?.let { "simkl-playback:$it" } ?: "simkl-playback:$parentId:${season ?: -1}:${episodeNumber ?: -1}", ) } +private fun SimklMedia.simklTrackingProviderItemId(): String? = + ids.simklIdValue()?.toLongOrNull()?.takeIf { it > 0L }?.let { id -> "simkl:$id" } + private fun SimklLibraryEntry.matchesContentId(contentId: String): Boolean { val media = media ?: return false if (media.canonicalContentId().equals(contentId, ignoreCase = true)) return true diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingAttribution.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingAttribution.kt new file mode 100644 index 00000000..38a0cd71 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingAttribution.kt @@ -0,0 +1,35 @@ +package com.nuvio.app.features.tracking + +interface TrackingAttributedItem { + val trackingContentId: String + val trackingProviderId: String? + val trackingProviderItemId: String? + val trackingSourceUrl: String? +} + +data class TrackingAttribution( + val providerId: TrackingProviderId, + val providerItemId: String?, + val sourceUrl: String, +) + +internal fun resolveTrackingAttribution( + contentId: String, + providerId: TrackingProviderId, + items: Sequence, +): TrackingAttribution? = items.firstNotNullOfOrNull { item -> + val sourceUrl = item.trackingSourceUrl?.trim()?.takeIf(String::isNotEmpty) + if ( + sourceUrl != null && + item.trackingContentId.equals(contentId, ignoreCase = true) && + TrackingProviderId.fromStorage(item.trackingProviderId) == providerId + ) { + TrackingAttribution( + providerId = providerId, + providerItemId = item.trackingProviderItemId, + sourceUrl = sourceUrl, + ) + } else { + null + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt index 2aab015a..4a27fd4e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.watched import com.nuvio.app.core.time.parseZonedIsoDateTimeToEpochMs import com.nuvio.app.features.home.MetaPreview +import com.nuvio.app.features.tracking.TrackingAttributedItem import com.nuvio.app.features.watching.domain.WatchingContentRef import com.nuvio.app.features.watching.domain.watchedKey import kotlinx.serialization.Serializable @@ -15,8 +16,14 @@ data class WatchedItem( val releaseInfo: String? = null, val season: Int? = null, val episode: Int? = null, + override val trackingProviderId: String? = null, + override val trackingProviderItemId: String? = null, + override val trackingSourceUrl: String? = null, val markedAtEpochMs: Long, -) +) : TrackingAttributedItem { + override val trackingContentId: String + get() = id +} data class WatchedUiState( val items: List = emptyList(), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt index c5e7e0bd..b7a7a8c0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt @@ -3,6 +3,7 @@ package com.nuvio.app.features.watchprogress import com.nuvio.app.features.cloud.CloudLibraryContentType import com.nuvio.app.features.cloud.cloudLibraryProviderPosterUrl import com.nuvio.app.features.details.MetaVideo +import com.nuvio.app.features.tracking.TrackingAttributedItem import com.nuvio.app.features.watching.domain.WatchingContentRef import kotlinx.serialization.Serializable @@ -53,9 +54,15 @@ data class WatchProgressEntry( val isCompleted: Boolean = false, val progressPercent: Float? = null, val source: String = WatchProgressSourceLocal, + override val trackingProviderId: String? = null, + override val trackingProviderItemId: String? = null, + override val trackingSourceUrl: String? = null, /** Stable server/storage identity. [videoId] remains the playback identity. */ val progressKey: String? = null, -) { +) : TrackingAttributedItem { + override val trackingContentId: String + get() = parentMetaId + val normalizedProgressPercent: Float? get() = progressPercent?.coerceIn(0f, 100f) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt index af2db560..361a6ecc 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt @@ -113,7 +113,10 @@ class SimklProjectionsTest { ).toSimklWatchedProjection() assertEquals(3, projection.items.size) - assertNotNull(projection.items.singleOrNull { it.type == "movie" }) + val movieEvent = assertNotNull(projection.items.singleOrNull { it.type == "movie" }) + assertEquals("simkl", movieEvent.trackingProviderId) + assertEquals("simkl:53536", movieEvent.trackingProviderItemId) + assertEquals("https://simkl.com/movies/53536", movieEvent.trackingSourceUrl) val episode = projection.items.single { it.season == 1 && it.episode == 1 } assertEquals("tt1520211", episode.id) assertFalse(projection.items.any { it.episode == 2 }) @@ -197,6 +200,9 @@ class SimklProjectionsTest { assertEquals(1_266_000L, entry.lastPositionMs) assertEquals("simkl-playback:12345", entry.progressKey) assertEquals(WatchProgressSourceSimklPlayback, entry.source) + assertEquals("simkl", entry.trackingProviderId) + assertEquals("simkl:39687", entry.trackingProviderItemId) + assertEquals("https://simkl.com/tv/39687", entry.trackingSourceUrl) assertTrue(entry.poster.orEmpty().contains("simkl.in/posters/12/poster_ca.webp")) assertFalse(entry.isCompleted) assertEquals(1_714_515_180_250L, entry.lastUpdatedEpochMs) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingAttributionTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingAttributionTest.kt new file mode 100644 index 00000000..92fdfde8 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingAttributionTest.kt @@ -0,0 +1,59 @@ +package com.nuvio.app.features.tracking + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class TrackingAttributionTest { + @Test + fun `resolver matches content and provider without depending on active source`() { + val items = sequenceOf( + attributedItem( + contentId = "tt123", + providerId = "trakt", + sourceUrl = "https://trakt.tv/movies/123", + ), + attributedItem( + contentId = "TT123", + providerId = "simkl", + sourceUrl = "https://simkl.com/movies/456", + providerItemId = "simkl:456", + ), + ) + + val result = resolveTrackingAttribution( + contentId = "tt123", + providerId = TrackingProviderId.SIMKL, + items = items, + ) + + assertEquals("simkl:456", result?.providerItemId) + assertEquals("https://simkl.com/movies/456", result?.sourceUrl) + } + + @Test + fun `resolver rejects missing links and mismatched content`() { + assertNull( + resolveTrackingAttribution( + contentId = "tt123", + providerId = TrackingProviderId.SIMKL, + items = sequenceOf( + attributedItem("tt999", "simkl", "https://simkl.com/movies/999"), + attributedItem("tt123", "simkl", null), + ), + ), + ) + } + + private fun attributedItem( + contentId: String, + providerId: String, + sourceUrl: String?, + providerItemId: String? = null, + ): TrackingAttributedItem = object : TrackingAttributedItem { + override val trackingContentId = contentId + override val trackingProviderId = providerId + override val trackingProviderItemId = providerItemId + override val trackingSourceUrl = sourceUrl + } +} From 6c61ab150b0b3c74432c90508ff40b808ab58424 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:38:54 +0530 Subject: [PATCH 29/59] fix(simkl): sanitize sync diagnostics --- .../app/features/simkl/SimklApiClient.kt | 26 ++++---- .../simkl/SimklApplicationAdapters.kt | 8 ++- .../features/simkl/SimklWatchDiagnostics.kt | 63 +++++-------------- .../app/features/simkl/SimklApiClientTest.kt | 25 ++++++++ 4 files changed, 57 insertions(+), 65 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt index 9e775b9b..b51d361b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt @@ -15,7 +15,6 @@ import kotlin.math.max import kotlin.random.Random private const val SIMKL_MAX_RESPONSE_BODY_BYTES = 8 * 1024 * 1024 -private const val SIMKL_RESPONSE_LOG_PREVIEW_CHARS = 2_048 private val simklSyncLog = Logger.withTag("SimklSync") internal enum class SimklHttpMethod { @@ -196,20 +195,19 @@ private fun logSyncReadResponse( ) { if (request.method != SimklHttpMethod.GET || !request.path.startsWith("/sync/")) return - val preview = response.body - .take(SIMKL_RESPONSE_LOG_PREVIEW_CHARS) - .replace("\r", "\\r") - .replace("\n", "\\n") - .ifEmpty { "" } - val truncationMarker = if (response.body.length > SIMKL_RESPONSE_LOG_PREVIEW_CHARS) "…" else "" - val contentType = response.headers.headerValue("content-type") ?: "" + simklSyncLog.d { simklSyncResponseLogMessage(request, response, attempt) } +} - simklSyncLog.i { - "Simkl HTTP response: method=${request.method} path=${request.path} " + - "query=${request.query} status=${response.status} attempt=$attempt " + - "contentType=$contentType bodyChars=${response.body.length} " + - "bodyPreview=$preview$truncationMarker" - } +internal fun simklSyncResponseLogMessage( + request: SimklApiRequest, + response: RawHttpResponse, + attempt: Int, +): String { + val queryKeys = request.query.keys.sorted().joinToString(prefix = "[", postfix = "]") + val contentType = response.headers.headerValue("content-type") ?: "" + return "Simkl HTTP response: method=${request.method} path=${request.path} " + + "queryKeys=$queryKeys status=${response.status} attempt=$attempt " + + "contentType=$contentType bodyChars=${response.body.length}" } internal object SimklApi { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt index 7ef348a6..0aa1c89d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt @@ -31,7 +31,6 @@ object SimklWatchedSyncAdapter : TrackingWatchedProvider { val projection = snapshot.toSimklWatchedProjection() SimklWatchDiagnostics.logProjection( stage = "items-pull", - profileId = profileId, snapshot = snapshot, projection = projection, ) @@ -45,7 +44,6 @@ object SimklWatchedSyncAdapter : TrackingWatchedProvider { val projection = snapshot.toSimklWatchedProjection() SimklWatchDiagnostics.logProjection( stage = "fully-watched-pull", - profileId = profileId, snapshot = snapshot, projection = projection, ) @@ -149,7 +147,11 @@ object SimklProgressRepository { } catch (error: CancellationException) { throw error } catch (error: Throwable) { - log.w { "Failed to remove Simkl playback $sessionId: ${error.message}" } + val apiError = error as? SimklApiException + log.w { + "Failed to remove Simkl playback: status=${apiError?.status} " + + "code=${apiError?.errorCode ?: "transport_failure"}" + } } } SimklSyncRepository.commitPlaybackRemoval(removed) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklWatchDiagnostics.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklWatchDiagnostics.kt index 9460ede7..7fd359a7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklWatchDiagnostics.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklWatchDiagnostics.kt @@ -2,10 +2,8 @@ package com.nuvio.app.features.simkl import co.touchlab.kermit.Logger import com.nuvio.app.features.tracking.TrackingRefreshIntent -import com.nuvio.app.features.watched.watchedItemKey internal object SimklWatchDiagnostics { - private const val SAMPLE_LIMIT = 10 private val log = Logger.withTag("SimklDiag") fun logSnapshot( @@ -21,24 +19,7 @@ internal object SimklWatchDiagnostics { season.episodes.count { episode -> !episode.watchedAt.isNullOrBlank() } } } - val entrySamples = entries - .asSequence() - .filter { entry -> - entry.status == SimklListStatus.COMPLETED || - entry.watchedEpisodesCount > 0 || - entry.seasons.isNotEmpty() - } - .take(SAMPLE_LIMIT) - .joinToString(separator = ",") { entry -> - val exactRows = entry.seasons.sumOf { season -> - season.episodes.count { episode -> !episode.watchedAt.isNullOrBlank() } - } - "${entry.mediaType.apiValue}:${entry.media?.canonicalContentId() ?: "missing"}:" + - "${entry.status?.apiValue ?: "none"}:" + - "${entry.watchedEpisodesCount}/${entry.totalEpisodesCount}:exact=$exactRows" - } - - log.i { + log.d { "snapshot stage=$stage initialized=${snapshot.isInitialized} entries=${entries.size} " + "movies=${entries.count { it.mediaType == SimklMediaType.MOVIES }} " + "shows=${entries.count { it.mediaType == SimklMediaType.SHOWS }} " + @@ -51,8 +32,7 @@ internal object SimklWatchDiagnostics { "playbackMovies=${snapshot.playback.count { it.mediaType == SimklMediaType.MOVIES }} " + "playbackEpisodes=${snapshot.playback.count { it.mediaType != SimklMediaType.MOVIES }} " + "missingCanonicalIds=${entries.count { it.media?.canonicalContentId() == null }} " + - "watermark=${snapshot.watermark} lastChecked=${snapshot.lastCheckedAtEpochMs} " + - "samples=[$entrySamples]" + "hasWatermark=${snapshot.watermark != null} hasLastChecked=${snapshot.lastCheckedAtEpochMs != null}" } } @@ -63,10 +43,10 @@ internal object SimklWatchDiagnostics { snapshot: SimklSyncSnapshot, errorMessage: String?, ) { - log.i { + log.d { "refresh request intent=$intent generation=$profileGeneration authenticated=$authenticated " + - "initialized=${snapshot.isInitialized} lastChecked=${snapshot.lastCheckedAtEpochMs} " + - "watermark=${snapshot.watermark} hasError=${errorMessage != null}" + "initialized=${snapshot.isInitialized} hasLastChecked=${snapshot.lastCheckedAtEpochMs != null} " + + "hasWatermark=${snapshot.watermark != null} hasError=${errorMessage != null}" } } @@ -78,9 +58,9 @@ internal object SimklWatchDiagnostics { hasError: Boolean, eligible: Boolean, ) { - log.i { + log.d { "refresh decision intent=$intent eligible=$eligible authenticated=$authenticated " + - "hasError=$hasError now=$nowEpochMs lastChecked=$lastCheckedAtEpochMs " + + "hasError=$hasError " + "elapsedMs=${lastCheckedAtEpochMs?.let { nowEpochMs - it }}" } } @@ -90,42 +70,29 @@ internal object SimklWatchDiagnostics { before: SimklSyncUiState, after: SimklSyncUiState, ) { - log.i { - "refresh completion intent=$intent checkedBefore=${before.snapshot.lastCheckedAtEpochMs} " + - "checkedAfter=${after.snapshot.lastCheckedAtEpochMs} " + - "watermarkBefore=${before.snapshot.watermark} watermarkAfter=${after.snapshot.watermark} " + + log.d { + "refresh completion intent=$intent checkedChanged=" + + "${before.snapshot.lastCheckedAtEpochMs != after.snapshot.lastCheckedAtEpochMs} " + + "watermarkChanged=${before.snapshot.watermark != after.snapshot.watermark} " + "entriesBefore=${before.snapshot.entries.size} entriesAfter=${after.snapshot.entries.size} " + "playbackBefore=${before.snapshot.playback.size} playbackAfter=${after.snapshot.playback.size} " + - "loading=${after.isLoading} hasLoaded=${after.hasLoaded} error=${after.errorMessage}" + "loading=${after.isLoading} hasLoaded=${after.hasLoaded} hasError=${after.errorMessage != null}" } } fun logProjection( stage: String, - profileId: Int, snapshot: SimklSyncSnapshot, projection: SimklWatchedProjection, ) { val items = projection.items - val itemSamples = items - .asSequence() - .take(SAMPLE_LIMIT) - .joinToString(separator = ",") { item -> - watchedItemKey(item.type, item.id, item.season, item.episode) - } - val fullyWatchedSamples = projection.fullyWatchedSeriesKeys - .asSequence() - .take(SAMPLE_LIMIT) - .joinToString(separator = ",") - - log.i { - "watched projection stage=$stage profile=$profileId inputEntries=${snapshot.entries.size} " + + log.d { + "watched projection stage=$stage inputEntries=${snapshot.entries.size} " + "inputExactEpisodeRows=${snapshot.exactWatchedEpisodeRowCount()} " + "outputItems=${items.size} outputMovies=${items.count { it.type == "movie" }} " + "outputEpisodes=${items.count { it.season != null && it.episode != null }} " + "outputSeriesSummaries=${items.count { it.type == "series" && it.season == null }} " + - "fullyWatchedSeries=${projection.fullyWatchedSeriesKeys.size} " + - "itemKeys=[$itemSamples] fullyWatchedKeys=[$fullyWatchedSamples]" + "fullyWatchedSeries=${projection.fullyWatchedSeriesKeys.size}" } } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt index 2aa849ca..b51b0c28 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt @@ -86,6 +86,31 @@ class SimklApiClientTest { assertEquals("application/json", request.headers["Content-Type"]) } + @Test + fun `sync response diagnostics exclude response bodies and query values`() { + val body = """{"movies":[{"movie":{"title":"Private title"}}]}""" + val request = SimklApiRequest( + method = SimklHttpMethod.GET, + path = "/sync/all-items/", + query = mapOf("date_from" to "2026-07-22T08:48:07Z"), + ) + + val message = simklSyncResponseLogMessage( + request = request, + response = response( + status = 200, + body = body, + headers = mapOf("Content-Type" to "application/json"), + ), + attempt = 1, + ) + + assertTrue("queryKeys=[date_from]" in message) + assertTrue("bodyChars=${body.length}" in message) + assertFalse("Private title" in message) + assertFalse("2026-07-22T08:48:07Z" in message) + } + @Test fun `single use unauthenticated posts keep metadata and never retry`() = runBlocking { val engine = RecordingEngine(response(503), response(200)) From 58e57f2b17c5c92d520637e56399bd309867a4f8 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:16:30 +0530 Subject: [PATCH 30/59] fix(watched): preserve failed Nuvio writes --- .../app/features/watched/WatchedRepository.kt | 28 ++++++++++++++----- .../features/watched/WatchedRepositoryTest.kt | 27 ++++++++++++++++++ 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt index 73324022..0f4a7ee1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt @@ -47,6 +47,11 @@ internal enum class WatchedTrackerHistorySync { Skip, } +internal data class WatchedPushOutcome( + val nuvioSyncSucceeded: Boolean = false, + val succeededTrackerProviderIds: Set = emptySet(), +) + internal fun shouldMirrorWatchedMarkToTrackers( sync: WatchedTrackerHistorySync, hasConnectedTracker: Boolean, @@ -74,6 +79,11 @@ internal fun watchedItemsForSource( internal fun shouldPersistWatchedSource(source: WatchProgressSource): Boolean = source.providerId == null +internal fun shouldAcknowledgeNuvioWatchedPush( + source: WatchProgressSource, + outcome: WatchedPushOutcome, +): Boolean = shouldPersistWatchedSource(source) && outcome.nuvioSyncSucceeded + internal fun replaceWatchedItemsForSource( source: WatchProgressSource, nuvioItems: MutableMap, @@ -881,13 +891,13 @@ object WatchedRepository { accountScopeSnapshot().launch { runCatching { if (items.isEmpty()) return@runCatching - val pushed = pushToTargetsForSource( + val outcome = pushToTargetsForSource( profileId = profileId, items = items, trackerHistorySync = trackerHistorySync, source = source, ) - if (pushed && shouldPersistWatchedSource(source)) { + if (shouldAcknowledgeNuvioWatchedPush(source = source, outcome = outcome)) { recordSuccessfulPush( profileId = profileId, operationGeneration = operationGeneration, @@ -1009,12 +1019,13 @@ object WatchedRepository { items: Collection, trackerHistorySync: WatchedTrackerHistorySync, source: WatchProgressSource, - ): Boolean { - var anySucceeded = false + ): WatchedPushOutcome { + var nuvioSyncSucceeded = false + val succeededTrackerProviderIds = linkedSetOf() if (source.providerId == null) { try { syncAdapter.push(profileId = profileId, items = items) - anySucceeded = true + nuvioSyncSucceeded = true } catch (error: CancellationException) { throw error } catch (error: Throwable) { @@ -1026,7 +1037,7 @@ object WatchedRepository { TrackingProviderRegistry.connectedWatchedProviders().forEach { provider -> try { provider.push(profileId = profileId, items = items) - anySucceeded = true + succeededTrackerProviderIds += provider.providerId } catch (error: CancellationException) { throw error } catch (error: Throwable) { @@ -1034,7 +1045,10 @@ object WatchedRepository { } } } - return anySucceeded + return WatchedPushOutcome( + nuvioSyncSucceeded = nuvioSyncSucceeded, + succeededTrackerProviderIds = succeededTrackerProviderIds, + ) } private suspend fun deleteFromTargetsForSource( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt index e7de03c5..38f65f33 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt @@ -136,6 +136,33 @@ class WatchedRepositoryTest { assertEquals(setOf(pendingKey), remainingDirtyKeys) } + @Test + fun successfulTrackerPush_doesNotAcknowledgeFailedNuvioPush() { + val outcome = WatchedPushOutcome( + nuvioSyncSucceeded = false, + succeededTrackerProviderIds = setOf(TrackingProviderId.TRAKT), + ) + + assertFalse( + shouldAcknowledgeNuvioWatchedPush( + source = WatchProgressSource.NUVIO_SYNC, + outcome = outcome, + ), + ) + } + + @Test + fun successfulNuvioPush_acknowledgesNuvioDirtyState() { + val outcome = WatchedPushOutcome(nuvioSyncSucceeded = true) + + assertTrue( + shouldAcknowledgeNuvioWatchedPush( + source = WatchProgressSource.NUVIO_SYNC, + outcome = outcome, + ), + ) + } + @Test fun playbackCompletionWatchedMarks_doNotMirrorToTrackerHistory() { assertFalse( From cac2c891c040f19fc25cb071533ab5ff656cdbdc Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:19:12 +0530 Subject: [PATCH 31/59] fix(trakt): preserve episode mapping identity --- .../app/features/tracking/TrackingMedia.kt | 12 +++++ .../features/trakt/TraktScrobbleRepository.kt | 52 +++++++++++++------ .../features/tracking/TrackingMediaTest.kt | 3 ++ .../trakt/TraktScrobbleRepositoryTest.kt | 28 ++++++++++ 4 files changed, 80 insertions(+), 15 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepositoryTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingMedia.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingMedia.kt index 16bbeeb7..16faa8eb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingMedia.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingMedia.kt @@ -48,12 +48,19 @@ data class TrackingEpisode( val title: String? = null, ) +data class TrackingCatalogReference( + val contentId: String, + val contentType: String, + val videoId: String? = null, +) + data class TrackingMediaReference( val kind: TrackingMediaKind, val title: String? = null, val year: Int? = null, val ids: TrackingExternalIds = TrackingExternalIds(), val episode: TrackingEpisode? = null, + val catalog: TrackingCatalogReference? = null, ) { val hasResolvableIdentity: Boolean get() = ids.hasAny || !title.isNullOrBlank() @@ -146,6 +153,11 @@ fun buildTrackingMediaReference( title = episodeTitle, ) }, + catalog = TrackingCatalogReference( + contentId = parentMetaId.trim(), + contentType = contentType.trim(), + videoId = videoId?.trim()?.takeIf(String::isNotBlank), + ), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt index 55e7d304..632f46d3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt @@ -46,6 +46,33 @@ internal sealed interface TraktScrobbleItem { } } +internal data class TraktEpisodeMappingInput( + val contentId: String, + val contentType: String, + val videoId: String?, + val season: Int, + val episode: Int, + val episodeTitle: String?, +) + +internal fun TrackingMediaReference.toTraktEpisodeMappingInput(): TraktEpisodeMappingInput? { + val episodeReference = episode ?: return null + val season = episodeReference.season ?: return null + val contentId = catalog?.contentId?.takeIf(String::isNotBlank) + ?: ids.imdb?.takeIf(String::isNotBlank) + ?: ids.tmdb?.let { value -> "tmdb:$value" } + ?: ids.trakt?.let { value -> "trakt:$value" } + ?: return null + return TraktEpisodeMappingInput( + contentId = contentId, + contentType = catalog?.contentType?.takeIf(String::isNotBlank) ?: "series", + videoId = catalog?.videoId?.takeIf(String::isNotBlank), + season = season, + episode = episodeReference.number, + episodeTitle = episodeReference.title, + ) +} + internal object TraktScrobbleRepository : TrackingScrobbler { override val providerId: TrackingProviderId = TrackingProviderId.TRAKT @@ -170,27 +197,22 @@ internal object TraktScrobbleRepository : TrackingScrobbler { ) } - val episode = media.episode ?: return null - val season = episode.season ?: return null - val contentId = ids.imdb - ?: ids.tmdb?.let { value -> "tmdb:$value" } - ?: ids.trakt?.let { value -> "trakt:$value" } - ?: return null + val mappingInput = media.toTraktEpisodeMappingInput() ?: return null val mappedEpisode = TraktEpisodeMappingService.resolveEpisodeMapping( - contentId = contentId, - contentType = "series", - videoId = null, - season = season, - episode = episode.number, - episodeTitle = episode.title, + contentId = mappingInput.contentId, + contentType = mappingInput.contentType, + videoId = mappingInput.videoId, + season = mappingInput.season, + episode = mappingInput.episode, + episodeTitle = mappingInput.episodeTitle, ) return TraktScrobbleItem.Episode( showTitle = media.title, showYear = media.year, showIds = ids, - season = mappedEpisode?.season ?: season, - number = mappedEpisode?.episode ?: episode.number, - episodeTitle = episode.title, + season = mappedEpisode?.season ?: mappingInput.season, + number = mappedEpisode?.episode ?: mappingInput.episode, + episodeTitle = mappingInput.episodeTitle, ) } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingMediaTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingMediaTest.kt index f6c5d1be..1bf98f73 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingMediaTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingMediaTest.kt @@ -51,5 +51,8 @@ class TrackingMediaTest { assertEquals(2016, media.year) assertEquals(2, media.episode?.season) assertEquals(7, media.episode?.number) + assertEquals("addon_specific_identifier", media.catalog?.contentId) + assertEquals("series", media.catalog?.contentType) + assertEquals("tt4574334:2:7", media.catalog?.videoId) } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepositoryTest.kt new file mode 100644 index 00000000..bb6f6487 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepositoryTest.kt @@ -0,0 +1,28 @@ +package com.nuvio.app.features.trakt + +import com.nuvio.app.features.tracking.buildTrackingMediaReference +import kotlin.test.Test +import kotlin.test.assertEquals + +class TraktScrobbleRepositoryTest { + @Test + fun episodeMappingInput_preservesAddonCatalogIdentity() { + val media = buildTrackingMediaReference( + contentType = "anime", + parentMetaId = "anime-addon:the-crow-girl", + videoId = "tt33307200:1:3", + title = "The Crow Girl", + seasonNumber = 1, + episodeNumber = 3, + episodeTitle = "Episode 3", + ) + + val input = media.toTraktEpisodeMappingInput() + + assertEquals("anime-addon:the-crow-girl", input?.contentId) + assertEquals("anime", input?.contentType) + assertEquals("tt33307200:1:3", input?.videoId) + assertEquals(1, input?.season) + assertEquals(3, input?.episode) + } +} From 2f19d06fd36bf9eee0c0fd2bc3d63f1f2e11aed6 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:26:10 +0530 Subject: [PATCH 32/59] fix(trakt): restore seek scrobble updates --- .../player/PlayerScreenRuntimeEffects.kt | 12 +++- .../PlayerScreenRuntimePlaybackActions.kt | 62 +++++++++++++++++++ .../player/PlayerScreenRuntimeState.kt | 1 + .../tracking/TrackingScrobbleCoordinator.kt | 35 +++++++++++ .../app/features/tracking/TrackingWrites.kt | 7 +++ .../features/trakt/TraktScrobbleRepository.kt | 3 + .../player/PlayerScreenRuntimeStateTest.kt | 24 +++++++ .../TrackingScrobbleCoordinatorTest.kt | 27 ++++++++ 8 files changed, 170 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt index b06d0b7c..e1046a32 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt @@ -69,6 +69,7 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() { initialLoadCompleted = false lastProgressPersistEpochMs = 0L previousIsPlaying = false + pendingSeekScrobbleRestart = false seekProgressSyncJob?.cancel() seekProgressSyncJob = null accumulatedSeekResetJob?.cancel() @@ -335,14 +336,23 @@ private fun PlayerScreenRuntime.BindPlayerUiVisibilityEffects() { if (playbackSnapshot.isEnded) { flushWatchProgress(TrackingScrobbleAction.STOP) previousIsPlaying = false + pendingSeekScrobbleRestart = false return@LaunchedEffect } if (previousIsPlaying && !playbackSnapshot.isPlaying && !playbackSnapshot.isLoading) { + pendingSeekScrobbleRestart = false flushWatchProgress(TrackingScrobbleAction.PAUSE) } - if (!previousIsPlaying && playbackSnapshot.isPlaying) { + if (playbackSnapshot.isPlaying && pendingSeekScrobbleRestart) { + pendingSeekScrobbleRestart = false + if (hasRequestedScrobbleStartForCurrentItem) { + emitTrackingSeekScrobbleStart() + } else { + emitTrackingScrobbleStart() + } + } else if (!previousIsPlaying && playbackSnapshot.isPlaying) { emitTrackingScrobbleStart() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt index 983b6637..33bcf36f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt @@ -59,6 +59,7 @@ internal fun PlayerScreenRuntime.resetIdentityStateIfNeeded() { (activeInitialProgressFraction == null || activeInitialProgressFraction!! <= 0f) lastProgressPersistEpochMs = 0L previousIsPlaying = false + pendingSeekScrobbleRestart = false autoFetchedAddonSubtitlesForKey = null trackPreferenceRestoreApplied = false preferredAudioSelectionApplied = false @@ -70,6 +71,7 @@ internal fun PlayerScreenRuntime.resetIdentityStateIfNeeded() { lastResetVideoIdentity = videoIdentity hasRequestedScrobbleStartForCurrentItem = false scrobbleStartRequestGeneration = 0L + pendingSeekScrobbleRestart = false hasSentCompletionScrobbleForCurrentItem = false currentTrackingMedia = null } @@ -179,6 +181,7 @@ private fun PlayerScreenRuntime.emitTrackingScrobbleTerminal( } currentTrackingMedia = null hasRequestedScrobbleStartForCurrentItem = false + pendingSeekScrobbleRestart = false scrobbleStartRequestGeneration += 1L } @@ -195,6 +198,28 @@ internal fun PlayerScreenRuntime.emitStopScrobbleForCurrentProgress() { } } +internal fun shouldUpdateTrackingScrobbleAfterSeek( + hasActiveScrobble: Boolean, + progressPercent: Float, +): Boolean = hasActiveScrobble && progressPercent >= 1f && progressPercent < 80f + +internal fun PlayerScreenRuntime.emitTrackingSeekScrobbleStart() { + val mediaSnapshot = currentTrackingMedia + val inputsSnapshot = snapshotTrackingScrobbleItemInputs() + scope.launch { + val media = mediaSnapshot ?: inputsSnapshot.buildMedia() + if (!media.hasResolvableIdentity) return@launch + TrackingScrobbleCoordinator.scrobbleSeek( + profileId = profileId, + action = TrackingScrobbleAction.START, + event = TrackingScrobbleEvent( + media = media, + progressPercent = currentPlaybackProgressPercent().toDouble(), + ), + ) + } +} + internal fun PlayerScreenRuntime.tryShowParentalGuide() { if (!playerSettingsUiState.showParentalGuide) return if (!parentalGuideHasShown && parentalWarnings.isNotEmpty() && !playbackStartedForParentalGuide) { @@ -230,6 +255,7 @@ internal fun PlayerScreenRuntime.flushWatchProgress( } internal fun PlayerScreenRuntime.scheduleProgressSyncAfterSeek() { + val shouldRestartScrobbleAfterSeek = shouldPlay || playbackSnapshot.isPlaying seekProgressSyncJob?.cancel() seekProgressSyncJob = scope.launch { delay(PlayerSeekProgressSyncDebounceMs) @@ -238,6 +264,42 @@ internal fun PlayerScreenRuntime.scheduleProgressSyncAfterSeek() { snapshot = playbackSnapshot, ) + val progressPercent = currentPlaybackProgressPercent() + if ( + !shouldUpdateTrackingScrobbleAfterSeek( + hasActiveScrobble = hasRequestedScrobbleStartForCurrentItem, + progressPercent = progressPercent, + ) + ) { + return@launch + } + + val media = currentTrackingMedia ?: currentTrackingMedia() + if (!media.hasResolvableIdentity) return@launch + val stopEvent = TrackingScrobbleEvent( + media = media, + progressPercent = progressPercent.toDouble(), + ) + scope.launch { + TrackingScrobbleCoordinator.scrobbleSeek( + profileId = profileId, + action = TrackingScrobbleAction.STOP, + event = stopEvent, + ) + if (!shouldRestartScrobbleAfterSeek || !shouldPlay || playbackSnapshot.isEnded) return@launch + if (playbackSnapshot.isPlaying) { + pendingSeekScrobbleRestart = false + TrackingScrobbleCoordinator.scrobbleSeek( + profileId = profileId, + action = TrackingScrobbleAction.START, + event = stopEvent.copy( + progressPercent = currentPlaybackProgressPercent().toDouble(), + ), + ) + } else { + pendingSeekScrobbleRestart = true + } + } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt index b0ca8916..65e71717 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt @@ -149,6 +149,7 @@ internal class PlayerScreenRuntime( var previousIsPlaying by mutableStateOf(false) var hasRequestedScrobbleStartForCurrentItem by mutableStateOf(false) var scrobbleStartRequestGeneration by mutableStateOf(0L) + var pendingSeekScrobbleRestart by mutableStateOf(false) var hasSentCompletionScrobbleForCurrentItem by mutableStateOf(false) var currentTrackingMedia by mutableStateOf(null) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinator.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinator.kt index 23e83be7..58c38637 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinator.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinator.kt @@ -35,8 +35,43 @@ object TrackingScrobbleCoordinator { } return failures } + + suspend fun scrobbleSeek( + profileId: Int, + action: TrackingScrobbleAction, + event: TrackingScrobbleEvent, + ): List { + if (profileId != ProfileRepository.activeProfileId) return emptyList() + TrackingProviderRegistry.ensureLoaded() + val failures = dispatchTrackingSeekScrobble( + scrobblers = TrackingProviderRegistry.connectedScrobblers(), + profileId = profileId, + action = action, + event = event, + ) + failures.forEach { failure -> + log.w(failure.cause) { + "${failure.providerId.storageId} seek scrobble ${action.wireValue} failed" + } + } + return failures + } } +internal suspend fun dispatchTrackingSeekScrobble( + scrobblers: Collection, + profileId: Int, + action: TrackingScrobbleAction, + event: TrackingScrobbleEvent, +): List = dispatchTrackingScrobble( + scrobblers = scrobblers.filter { scrobbler -> + scrobbler.seekScrobblePolicy == TrackingSeekScrobblePolicy.STOP_AND_RESTART + }, + profileId = profileId, + action = action, + event = event, +) + internal suspend fun dispatchTrackingScrobble( scrobblers: Collection, profileId: Int, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingWrites.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingWrites.kt index b74afa36..b1ecca30 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingWrites.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingWrites.kt @@ -19,6 +19,11 @@ enum class TrackingScrobbleAction(val wireValue: String) { STOP("stop"), } +enum class TrackingSeekScrobblePolicy { + NONE, + STOP_AND_RESTART, +} + data class TrackingHistoryItem( val media: TrackingMediaReference, val watchedAtEpochMs: Long? = null, @@ -78,6 +83,8 @@ interface TrackingHistoryWriter { interface TrackingScrobbler { val providerId: TrackingProviderId + val seekScrobblePolicy: TrackingSeekScrobblePolicy + get() = TrackingSeekScrobblePolicy.NONE suspend fun scrobble( profileId: Int, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt index 632f46d3..30958ffd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt @@ -11,6 +11,7 @@ import com.nuvio.app.features.tracking.TrackingProviderRegistry import com.nuvio.app.features.tracking.TrackingScrobbleAction import com.nuvio.app.features.tracking.TrackingScrobbleEvent import com.nuvio.app.features.tracking.TrackingScrobbler +import com.nuvio.app.features.tracking.TrackingSeekScrobblePolicy import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.serialization.SerialName @@ -75,6 +76,8 @@ internal fun TrackingMediaReference.toTraktEpisodeMappingInput(): TraktEpisodeMa internal object TraktScrobbleRepository : TrackingScrobbler { override val providerId: TrackingProviderId = TrackingProviderId.TRAKT + override val seekScrobblePolicy: TrackingSeekScrobblePolicy = + TrackingSeekScrobblePolicy.STOP_AND_RESTART private data class ScrobbleStamp( val profileId: Int, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeStateTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeStateTest.kt index 1d9091bb..608c4868 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeStateTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeStateTest.kt @@ -5,7 +5,9 @@ import androidx.compose.ui.Modifier import com.nuvio.app.features.streams.StreamsUiState import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue class PlayerScreenRuntimeStateTest { @@ -21,6 +23,28 @@ class PlayerScreenRuntimeStateTest { assertEquals("addon-id", selectedFilter.value) } + @Test + fun seekScrobbleUpdate_requiresActiveIncompletePlayback() { + assertTrue( + shouldUpdateTrackingScrobbleAfterSeek( + hasActiveScrobble = true, + progressPercent = 50f, + ), + ) + assertFalse( + shouldUpdateTrackingScrobbleAfterSeek( + hasActiveScrobble = false, + progressPercent = 50f, + ), + ) + assertFalse( + shouldUpdateTrackingScrobbleAfterSeek( + hasActiveScrobble = true, + progressPercent = 80f, + ), + ) + } + private fun testPlayerScreenArgs() = PlayerScreenArgs( profileId = 1, title = "Title", diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinatorTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinatorTest.kt index a70b1ccc..87dc33ba 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinatorTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinatorTest.kt @@ -29,8 +29,35 @@ class TrackingScrobbleCoordinatorTest { assertEquals(listOf(TrackingProviderId.SIMKL), failures.map(TrackingScrobbleFailure::providerId)) } + @Test + fun `seek fanout targets only providers that restart scrobbles`() = runBlocking { + val trakt = FakeScrobbler( + providerId = TrackingProviderId.TRAKT, + seekScrobblePolicy = TrackingSeekScrobblePolicy.STOP_AND_RESTART, + ) + val simkl = FakeScrobbler(TrackingProviderId.SIMKL) + val event = TrackingScrobbleEvent( + media = TrackingMediaReference( + kind = TrackingMediaKind.MOVIE, + ids = TrackingExternalIds(imdb = "tt0111161"), + ), + progressPercent = 55.0, + ) + + dispatchTrackingSeekScrobble( + scrobblers = listOf(trakt, simkl), + profileId = 2, + action = TrackingScrobbleAction.STOP, + event = event, + ) + + assertEquals(1, trakt.callCount) + assertEquals(0, simkl.callCount) + } + private class FakeScrobbler( override val providerId: TrackingProviderId, + override val seekScrobblePolicy: TrackingSeekScrobblePolicy = TrackingSeekScrobblePolicy.NONE, private val failure: Throwable? = null, ) : TrackingScrobbler { var callCount: Int = 0 From 7210e7cc6818347031d2a55987c6223215049cee Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:28:00 +0530 Subject: [PATCH 33/59] fix(sync): restore foreground refresh behavior --- .../src/commonMain/kotlin/com/nuvio/app/App.kt | 2 +- .../app/features/simkl/SimklApplicationAdapters.kt | 7 ++++--- .../app/features/simkl/SimklRefreshPolicyTest.kt | 12 ++++++++++++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index d403ff8b..564002b6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -1221,7 +1221,7 @@ private fun MainAppContent( val activeProfileId = profileState.activeProfile?.profileIndex ?: return@LaunchedEffect SyncManager.pullAllForProfile(activeProfileId) AppForegroundMonitor.events().collect { - SyncManager.requestForegroundPull(activeProfileId) + SyncManager.requestForegroundPull(activeProfileId, force = true) } } var resumePromptItem by remember { mutableStateOf(null) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt index 0aa1c89d..381ba2d5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt @@ -170,6 +170,9 @@ object SimklProgressRepository { } } +internal fun simklProgressRefreshIntent(sourceChanged: Boolean): TrackingRefreshIntent = + if (sourceChanged) TrackingRefreshIntent.INVALIDATED else TrackingRefreshIntent.AUTOMATIC + object SimklTrackingProgressProvider : TrackingProgressProvider { override val providerId: TrackingProviderId = TrackingProviderId.SIMKL override val changes: Flow = SimklProgressRepository.uiState.map { Unit } @@ -179,9 +182,7 @@ object SimklTrackingProgressProvider : TrackingProgressProvider { override fun onProfileChanged() = SimklProgressRepository.ensureLoaded() override suspend fun refresh(force: Boolean, sourceChanged: Boolean) = - SimklProgressRepository.refresh( - if (sourceChanged) TrackingRefreshIntent.INVALIDATED else TrackingRefreshIntent.AUTOMATIC, - ) + SimklProgressRepository.refresh(simklProgressRefreshIntent(sourceChanged)) override fun snapshot(): TrackingProgressSnapshot { val state = SimklProgressRepository.uiState.value diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt index 9a5455b9..1f7098b4 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt @@ -10,6 +10,18 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class SimklRefreshPolicyTest { + @Test + fun `progress refresh keeps provider policy unless the source changes`() { + assertEquals( + TrackingRefreshIntent.AUTOMATIC, + simklProgressRefreshIntent(sourceChanged = false), + ) + assertEquals( + TrackingRefreshIntent.INVALIDATED, + simklProgressRefreshIntent(sourceChanged = true), + ) + } + @Test fun `automatic refresh is throttled for fifteen minutes`() { val lastCheckedAt = 1_000L From 002a54e0228f34f9bf0ed6ae1eb88b2af6415750 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:31:08 +0530 Subject: [PATCH 34/59] fix(sync): retry incomplete foreground pulls --- .../com/nuvio/app/core/sync/SyncManager.kt | 102 ++++++++++-------- .../nuvio/app/core/sync/SyncManagerTest.kt | 33 ++++++ 2 files changed, 88 insertions(+), 47 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt index 76d1d63f..74370cb1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt @@ -62,6 +62,28 @@ internal data class ProfileSyncResult( get() = failedSteps.isEmpty() } +internal data class ProfilePullFreshness( + val profileId: Int? = null, + val completedAtEpochMs: Long = 0L, +) { + fun isRecent(profileId: Int, nowEpochMs: Long, minIntervalMs: Long): Boolean = + this.profileId == profileId && nowEpochMs - completedAtEpochMs < minIntervalMs + + fun recordIfSuccessful( + profileId: Int, + completedAtEpochMs: Long, + result: ProfileSyncResult, + ): ProfilePullFreshness = + if (result.succeeded) { + ProfilePullFreshness( + profileId = profileId, + completedAtEpochMs = completedAtEpochMs, + ) + } else { + this + } +} + internal suspend fun runOrderedProfileSync( profileId: Int, pluginsEnabled: Boolean, @@ -211,8 +233,7 @@ object SyncManager { private var foregroundPullProfileId: Int? = null private var periodicNuvioSyncPullJob: Job? = null private var periodicNuvioSyncProfileId: Int? = null - private var lastFullPullAtMs: Long = 0L - private var lastFullPullProfileId: Int? = null + private var pullFreshness = ProfilePullFreshness() private val profileSyncOperations = ProfileSyncOperations( pullAddons = { profileId -> AddonRepository.pullFromServer(profileId) }, @@ -247,8 +268,7 @@ object SyncManager { foregroundPullJob.also { foregroundPullJob = null foregroundPullProfileId = null - lastFullPullAtMs = 0L - lastFullPullProfileId = null + pullFreshness = ProfilePullFreshness() } } foregroundJob?.cancel() @@ -304,8 +324,11 @@ object SyncManager { private fun hasRecentFullPull(profileId: Int): Boolean = synchronized(pullStateLock) { - lastFullPullProfileId == profileId && - EpisodeReleaseDatePlatform.nowEpochMs() - lastFullPullAtMs < FOREGROUND_PULL_MIN_INTERVAL_MS + pullFreshness.isRecent( + profileId = profileId, + nowEpochMs = EpisodeReleaseDatePlatform.nowEpochMs(), + minIntervalMs = FOREGROUND_PULL_MIN_INTERVAL_MS, + ) } private suspend fun pullForegroundForProfile(profileId: Int) { @@ -313,42 +336,25 @@ object SyncManager { runCatching { ProfileRepository.pullProfiles() } .onFailure { log.e(it) { "Foreground profiles pull failed" } } - runCatching { ProfileSettingsSync.pull(profileId) } - .onFailure { log.e(it) { "Foreground profile settings pull failed" } } - - coroutineScope { - launch { - runCatching { AddonRepository.pullFromServer(profileId) } - .onFailure { log.e(it) { "Foreground addons pull failed" } } - } - if (AppFeaturePolicy.pluginsEnabled) { - launch { - runCatching { PluginRepository.pullFromServer(profileId) } - .onFailure { log.e(it) { "Foreground plugins pull failed" } } - } - } - launch { - runCatching { LibraryRepository.pullFromServer(profileId) } - .onFailure { log.e(it) { "Foreground library pull failed" } } - } - launch { - runCatching { - WatchProgressSourceCoordinator.refreshActiveSource(profileId = profileId, force = true) - }.onFailure { log.e(it) { "Foreground active watch source pull failed" } } - } - launch { - runCatching { CollectionSyncService.pullFromServer(profileId) } - .onFailure { log.e(it) { "Foreground collections pull failed" } } - } - launch { - runCatching { HomeCatalogSettingsSyncService.pullFromServer(profileId) } - .onFailure { log.e(it) { "Foreground home catalog settings pull failed" } } - } - } - + val syncResult = runOrderedProfileSync( + profileId = profileId, + pluginsEnabled = AppFeaturePolicy.pluginsEnabled, + operations = profileSyncOperations, + onFailure = { step, error -> + log.e(error) { "Foreground profile sync step failed profile=$profileId step=$step" } + }, + ) synchronized(pullStateLock) { - lastFullPullAtMs = EpisodeReleaseDatePlatform.nowEpochMs() - lastFullPullProfileId = profileId + pullFreshness = pullFreshness.recordIfSuccessful( + profileId = profileId, + completedAtEpochMs = EpisodeReleaseDatePlatform.nowEpochMs(), + result = syncResult, + ) + } + if (!syncResult.succeeded) { + log.w { + "Foreground profile sync incomplete profile=$profileId failedSteps=${syncResult.failedSteps}" + } } log.i { "Foreground sync completed profile=$profileId" } @@ -386,12 +392,14 @@ object SyncManager { } finally { WatchProgressSourceCoordinator.resumeAutomaticTransitions() } - if (syncResult.succeeded) { - synchronized(pullStateLock) { - lastFullPullAtMs = EpisodeReleaseDatePlatform.nowEpochMs() - lastFullPullProfileId = profileId - } - } else { + synchronized(pullStateLock) { + pullFreshness = pullFreshness.recordIfSuccessful( + profileId = profileId, + completedAtEpochMs = EpisodeReleaseDatePlatform.nowEpochMs(), + result = syncResult, + ) + } + if (!syncResult.succeeded) { log.w { "Full profile sync incomplete profile=$profileId reason=$reason " + "failedSteps=${syncResult.failedSteps}" diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/sync/SyncManagerTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/sync/SyncManagerTest.kt index 3168d35e..cd14db39 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/sync/SyncManagerTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/sync/SyncManagerTest.kt @@ -131,6 +131,39 @@ class SyncManagerTest { assertEquals(setOf(ProfileSyncStep.ActiveWatchSource), result.failedSteps) } + @Test + fun `failed profile sync does not advance foreground freshness`() { + val previous = ProfilePullFreshness( + profileId = 3, + completedAtEpochMs = 1_000L, + ) + val failed = previous.recordIfSuccessful( + profileId = 3, + completedAtEpochMs = 2_000L, + result = ProfileSyncResult(setOf(ProfileSyncStep.ActiveWatchSource)), + ) + val succeeded = previous.recordIfSuccessful( + profileId = 3, + completedAtEpochMs = 2_000L, + result = ProfileSyncResult(emptySet()), + ) + + assertEquals(previous, failed) + assertEquals(2_000L, succeeded.completedAtEpochMs) + assertFalse( + ProfilePullFreshness() + .recordIfSuccessful( + profileId = 3, + completedAtEpochMs = 2_000L, + result = ProfileSyncResult(setOf(ProfileSyncStep.ActiveWatchSource)), + ) + .isRecent(profileId = 3, nowEpochMs = 2_001L, minIntervalMs = 1_000L), + ) + assertTrue( + succeeded.isRecent(profileId = 3, nowEpochMs = 2_001L, minIntervalMs = 1_000L), + ) + } + @Test fun `realtime invalidation queued during active sync runs once afterwards`() = runBlocking { val gate = ProfileSyncRequestGate() From b4eba6e0c5a011c1d45d26d800c08b74ba9385f4 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:06:26 +0530 Subject: [PATCH 35/59] fix(simkl): fetch historical episode state --- .../app/features/simkl/SimklSyncEngine.kt | 13 ++--- .../app/features/simkl/SimklSyncRemote.kt | 50 ++++++++++++------- .../app/features/simkl/SimklSyncEngineTest.kt | 44 +++++++++------- 3 files changed, 62 insertions(+), 45 deletions(-) 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 index d7c846e6..b0dbceb9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncEngine.kt @@ -16,18 +16,11 @@ internal class SimklSyncEngine( } if (current.watermark == null) return initialSync() - val delta = remote.fetchAllItems( - SimklAllItemsRequest( - dateFrom = current.watermark, - includeEpisodeDetails = true, - ), - ) + val delta = remote.fetchAllItems(SimklAllItemsRequest.Changes(current.watermark)) var entries = mergeDelta(current.entries, delta) if (hasRemovalActivityChanged(current.activities, activities)) { - val authoritativeIds = remote.fetchAllItems( - SimklAllItemsRequest(idsOnly = true), - ) + val authoritativeIds = remote.fetchAllItems(SimklAllItemsRequest.CurrentIds) entries = reconcileRemovedEntries(entries, authoritativeIds) } @@ -52,7 +45,7 @@ internal class SimklSyncEngine( val entries = buildList { SimklMediaType.entries.forEach { type -> addAll( - remote.fetchAllItems(SimklAllItemsRequest(type = type)) + remote.fetchAllItems(SimklAllItemsRequest.Bootstrap(type)) .entriesFor(type), ) } 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 index 9279b073..f1bad9ca 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRemote.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRemote.kt @@ -6,12 +6,23 @@ import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.decodeFromJsonElement -internal data class SimklAllItemsRequest( - val type: SimklMediaType? = null, - val dateFrom: String? = null, - val includeEpisodeDetails: Boolean = false, - val idsOnly: Boolean = false, -) +internal sealed interface SimklAllItemsRequest { + val type: SimklMediaType? + + data class Bootstrap( + override val type: SimklMediaType, + ) : SimklAllItemsRequest + + data class Changes( + val dateFrom: String, + ) : SimklAllItemsRequest { + override val type: SimklMediaType? = null + } + + data object CurrentIds : SimklAllItemsRequest { + override val type: SimklMediaType? = null + } +} internal interface SimklSyncRemote { suspend fun fetchActivities(): SimklActivities @@ -38,17 +49,22 @@ internal class SimklApiSyncRemote( 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") - } - } + val query = when (request) { + is SimklAllItemsRequest.Bootstrap -> mapOf( + "extended" to "full", + "episode_watched_at" to "yes", + "include_all_episodes" to "yes", + ) + is SimklAllItemsRequest.Changes -> mapOf( + "date_from" to request.dateFrom, + "extended" to "full_anime_seasons", + "episode_watched_at" to "yes", + "episode_tvdb_id" to "yes", + "include_all_episodes" to "yes", + ) + SimklAllItemsRequest.CurrentIds -> mapOf( + "extended" to "simkl_ids_only", + ) } return client.execute( SimklApiRequest( 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 index 0a02dfb3..f9e0d3ab 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSyncEngineTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSyncEngineTest.kt @@ -50,9 +50,14 @@ class SimklSyncEngineTest { assertEquals(1, result.playback.size) assertEquals(500L, result.lastSyncedAtEpochMs) assertTrue(remote.isExhausted) - assertTrue(remote.allItemsRequests.all { request -> - request.dateFrom == null && !request.includeEpisodeDetails && !request.idsOnly - }) + assertEquals( + listOf( + SimklAllItemsRequest.Bootstrap(SimklMediaType.SHOWS), + SimklAllItemsRequest.Bootstrap(SimklMediaType.MOVIES), + SimklAllItemsRequest.Bootstrap(SimklMediaType.ANIME), + ), + remote.allItemsRequests, + ) } @Test @@ -112,9 +117,13 @@ class SimklSyncEngineTest { ) 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) + assertEquals( + listOf( + SimklAllItemsRequest.Changes("v1"), + SimklAllItemsRequest.CurrentIds, + ), + remote.allItemsRequests, + ) assertTrue(remote.isExhausted) } @@ -139,7 +148,7 @@ class SimklSyncEngineTest { } @Test - fun `remote keeps initial pull minimal and adds rich flags only to dated delta`() = runBlocking { + fun `remote applies documented bootstrap delta and reconciliation parameters`() = runBlocking { var now = 0L val urls = mutableListOf() val engine = SimklHttpEngine { _, url, _, _ -> @@ -156,21 +165,20 @@ class SimklSyncEngineTest { ) 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)) + remote.fetchAllItems(SimklAllItemsRequest.Bootstrap(SimklMediaType.SHOWS)) + remote.fetchAllItems(SimklAllItemsRequest.Changes("2026-05-08T14:23:11Z")) + remote.fetchAllItems(SimklAllItemsRequest.CurrentIds) assertFalse("date_from=" in urls[0]) - assertFalse("extended=" in urls[0]) + assertTrue("extended=full" in urls[0]) + assertTrue("episode_watched_at=yes" in urls[0]) + assertTrue("include_all_episodes=yes" in urls[0]) + assertFalse("episode_tvdb_id=" 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("episode_tvdb_id=yes" in urls[1]) + assertTrue("include_all_episodes=yes" in urls[1]) assertTrue("extended=simkl_ids_only" in urls[2]) } @@ -190,7 +198,7 @@ class SimklSyncEngineTest { ) val response = SimklApiSyncRemote(client).fetchAllItems( - SimklAllItemsRequest(type = SimklMediaType.ANIME), + SimklAllItemsRequest.Bootstrap(SimklMediaType.ANIME), ) assertTrue(response.entriesFor(SimklMediaType.ANIME).isEmpty()) From b817eb5b14d6e4286884815f11300082c4fc888b Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:24:10 +0530 Subject: [PATCH 36/59] feat(simkl): explain sync cadence --- .../composeResources/values/strings.xml | 6 + .../features/settings/SimklSyncInfoDialog.kt | 106 ++++++++++++++++++ .../features/settings/TrackingSettingsPage.kt | 22 ++++ .../app/features/simkl/SimklRefreshPolicy.kt | 4 +- .../features/simkl/SimklRefreshPolicyTest.kt | 1 + 5 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SimklSyncInfoDialog.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index e6315c3c..966f1b7c 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1139,6 +1139,12 @@ The Simkl sign-in request expired. Start again. Could not complete Simkl sign in. Please try again. Sync now + How syncing works + How Simkl syncing works + Nuvio automatically checks Simkl at most once every %1$d minutes. Changes made through another app or the Simkl website may take that long to appear. + Each refresh first checks whether Simkl reports any changes and downloads only the updates. This follows Simkl’s API rules, reduces unnecessary data use, and helps keep the service stable. + Changes made in Nuvio are sent to Simkl immediately. Use Sync now whenever you want Nuvio to check for remote changes sooner. + Read the Simkl sync guide Simkl authorization expired or was revoked. Connect again. Simkl Simkl watchlist selected diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SimklSyncInfoDialog.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SimklSyncInfoDialog.kt new file mode 100644 index 00000000..f5b449df --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SimklSyncInfoDialog.kt @@ -0,0 +1,106 @@ +package com.nuvio.app.features.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.nuvio.app.features.simkl.SIMKL_AUTOMATIC_REFRESH_INTERVAL_MINUTES +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_close +import nuvio.composeapp.generated.resources.settings_simkl_sync_info_activity +import nuvio.composeapp.generated.resources.settings_simkl_sync_info_description +import nuvio.composeapp.generated.resources.settings_simkl_sync_info_docs +import nuvio.composeapp.generated.resources.settings_simkl_sync_info_manual +import nuvio.composeapp.generated.resources.settings_simkl_sync_info_title +import nuvio.composeapp.generated.resources.settings_trakt_failed_open_browser +import org.jetbrains.compose.resources.stringResource + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +internal fun SimklSyncInfoDialog(onDismiss: () -> Unit) { + val uriHandler = LocalUriHandler.current + var browserError by rememberSaveable { mutableStateOf(false) } + + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(Res.string.settings_simkl_sync_info_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource( + Res.string.settings_simkl_sync_info_description, + SIMKL_AUTOMATIC_REFRESH_INTERVAL_MINUTES, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(Res.string.settings_simkl_sync_info_activity), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(Res.string.settings_simkl_sync_info_manual), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (browserError) { + Text( + text = stringResource(Res.string.settings_trakt_failed_open_browser), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton( + onClick = { + browserError = false + runCatching { uriHandler.openUri(SIMKL_SYNC_GUIDE_URL) } + .onFailure { browserError = true } + }, + ) { + Text(stringResource(Res.string.settings_simkl_sync_info_docs)) + } + TextButton(onClick = onDismiss) { + Text(stringResource(Res.string.action_close)) + } + } + } + } + } +} + +private const val SIMKL_SYNC_GUIDE_URL = "https://api.simkl.org/guides/sync" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingSettingsPage.kt index 87f38604..f7e39bae 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingSettingsPage.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Info import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -99,6 +100,7 @@ import nuvio.composeapp.generated.resources.settings_simkl_open_login import nuvio.composeapp.generated.resources.settings_simkl_sign_in_description import nuvio.composeapp.generated.resources.settings_simkl_sign_in_failed import nuvio.composeapp.generated.resources.settings_simkl_sync_now +import nuvio.composeapp.generated.resources.settings_simkl_sync_info_action import nuvio.composeapp.generated.resources.settings_simkl_visit import nuvio.composeapp.generated.resources.tracking_library_source_simkl_selected import nuvio.composeapp.generated.resources.tracking_source_simkl @@ -791,6 +793,7 @@ private fun SimklConnectionCard( SimklSyncRepository.state }.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() + var showSyncInfo by rememberSaveable { mutableStateOf(false) } ProviderConnectionCard( isTablet = isTablet, @@ -813,6 +816,7 @@ private fun SimklConnectionCard( openLoginLabel = stringResource(Res.string.settings_simkl_open_login), disconnectLabel = stringResource(Res.string.settings_simkl_disconnect), syncLabel = stringResource(Res.string.settings_simkl_sync_now), + infoLabel = stringResource(Res.string.settings_simkl_sync_info_action), isSyncing = syncState.isLoading, missingCredentialsMessage = stringResource(Res.string.settings_simkl_missing_credentials), errorMessage = simklErrorMessage(uiState.error) ?: syncState.errorMessage, @@ -829,8 +833,13 @@ private fun SimklConnectionCard( SimklSyncRepository.refresh(TrackingRefreshIntent.USER_INITIATED) } }, + onInfoRequested = { showSyncInfo = true }, onDisconnect = SimklAuthRepository::onDisconnectRequested, ) + + if (showSyncInfo) { + SimklSyncInfoDialog(onDismiss = { showSyncInfo = false }) + } } @Composable @@ -848,6 +857,7 @@ private fun ProviderConnectionCard( openLoginLabel: String, disconnectLabel: String, syncLabel: String? = null, + infoLabel: String? = null, isSyncing: Boolean = false, missingCredentialsMessage: String, statusMessage: String? = null, @@ -858,6 +868,7 @@ private fun ProviderConnectionCard( onResumeAuthorization: () -> String?, onCancelAuthorization: () -> Unit, onSyncRequested: (() -> Unit)? = null, + onInfoRequested: (() -> Unit)? = null, onDisconnect: () -> Unit, ) { val uriHandler = LocalUriHandler.current @@ -907,6 +918,17 @@ private fun ProviderConnectionCard( } } } + if (infoLabel != null && onInfoRequested != null) { + TextButton(onClick = onInfoRequested) { + Icon( + imageVector = Icons.Rounded.Info, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.size(8.dp)) + Text(infoLabel) + } + } Button( onClick = onDisconnect, enabled = !isLoading && !isSyncing, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicy.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicy.kt index c5ab7fe1..b40b5fd5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicy.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicy.kt @@ -5,7 +5,9 @@ import kotlinx.atomicfu.atomic import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -internal const val SIMKL_AUTOMATIC_REFRESH_INTERVAL_MS = 15L * 60L * 1_000L +internal const val SIMKL_AUTOMATIC_REFRESH_INTERVAL_MINUTES = 15 +internal const val SIMKL_AUTOMATIC_REFRESH_INTERVAL_MS = + SIMKL_AUTOMATIC_REFRESH_INTERVAL_MINUTES * 60L * 1_000L internal fun shouldRunSimklRefresh( intent: TrackingRefreshIntent, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt index 1f7098b4..849c3f8e 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt @@ -26,6 +26,7 @@ class SimklRefreshPolicyTest { fun `automatic refresh is throttled for fifteen minutes`() { val lastCheckedAt = 1_000L + assertEquals(15, SIMKL_AUTOMATIC_REFRESH_INTERVAL_MINUTES) assertFalse( shouldRunSimklRefresh( intent = TrackingRefreshIntent.AUTOMATIC, From 7fb8206107f5eb177aaa99f921565d27cfc764c7 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:38:10 +0530 Subject: [PATCH 37/59] feat(tracking): redesign tracking settings --- .../simkl/SimklBrandPainter.android.kt | 15 + .../res/drawable/simkl_logo_glyph.xml | 22 + .../res/drawable/simkl_logo_wordmark.xml | 13 + .../drawable/simkl_logo_glyph.svg | 1 + .../drawable/simkl_logo_wordmark.svg | 1 + .../drawable/trakt_logo_wordmark.svg | 2 +- .../composeResources/values/strings.xml | 16 +- .../features/settings/SettingsComponents.kt | 20 +- .../settings/TrackingAdaptivePicker.kt | 255 ++++ .../settings/TrackingProviderCards.kt | 765 ++++++++++ .../features/settings/TrackingSettingsPage.kt | 1288 +++++------------ .../app/features/simkl/SimklBrandPainter.kt | 12 + .../TrackingSettingsPresentationTest.kt | 64 + .../features/simkl/SimklBrandPainter.ios.kt | 15 + 14 files changed, 1581 insertions(+), 908 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.android.kt create mode 100644 composeApp/src/androidMain/res/drawable/simkl_logo_glyph.xml create mode 100644 composeApp/src/androidMain/res/drawable/simkl_logo_wordmark.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/simkl_logo_glyph.svg create mode 100644 composeApp/src/commonMain/composeResources/drawable/simkl_logo_wordmark.svg create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingAdaptivePicker.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingProviderCards.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/settings/TrackingSettingsPresentationTest.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.ios.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.android.kt new file mode 100644 index 00000000..a2ccc03c --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.android.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.simkl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import com.nuvio.app.R + +@Composable +actual fun simklBrandPainter(asset: SimklBrandAsset): Painter = + painterResource( + id = when (asset) { + SimklBrandAsset.Glyph -> R.drawable.simkl_logo_glyph + SimklBrandAsset.Wordmark -> R.drawable.simkl_logo_wordmark + }, + ) diff --git a/composeApp/src/androidMain/res/drawable/simkl_logo_glyph.xml b/composeApp/src/androidMain/res/drawable/simkl_logo_glyph.xml new file mode 100644 index 00000000..f53fead9 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/simkl_logo_glyph.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/composeApp/src/androidMain/res/drawable/simkl_logo_wordmark.xml b/composeApp/src/androidMain/res/drawable/simkl_logo_wordmark.xml new file mode 100644 index 00000000..e1d934cc --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/simkl_logo_wordmark.xml @@ -0,0 +1,13 @@ + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/simkl_logo_glyph.svg b/composeApp/src/commonMain/composeResources/drawable/simkl_logo_glyph.svg new file mode 100644 index 00000000..e16507c2 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/simkl_logo_glyph.svg @@ -0,0 +1 @@ + diff --git a/composeApp/src/commonMain/composeResources/drawable/simkl_logo_wordmark.svg b/composeApp/src/commonMain/composeResources/drawable/simkl_logo_wordmark.svg new file mode 100644 index 00000000..3860c456 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/simkl_logo_wordmark.svg @@ -0,0 +1 @@ + diff --git a/composeApp/src/commonMain/composeResources/drawable/trakt_logo_wordmark.svg b/composeApp/src/commonMain/composeResources/drawable/trakt_logo_wordmark.svg index 15d12e90..b7ecf23f 100644 --- a/composeApp/src/commonMain/composeResources/drawable/trakt_logo_wordmark.svg +++ b/composeApp/src/commonMain/composeResources/drawable/trakt_logo_wordmark.svg @@ -1,4 +1,4 @@ - + diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 966f1b7c..6a01bb0c 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1121,10 +1121,24 @@ Open Trakt Login Your Save actions can now target Trakt watchlist and personal lists. Sign in with Trakt to enable list-based saving and Trakt library mode. - Connect one or more tracking services. Playback events are sent to every connected tracker, while you choose one source for library and watch progress views. + DATA SOURCES SOURCE PREFERENCES + VIEWING AND DISCOVERY TRACKING SERVICES After approval, you will be redirected back automatically. + Connect %1$s first + Disconnect %1$s? + This removes the connection from Nuvio. Your data on %1$s stays intact, and unavailable library or progress sources temporarily fall back to Nuvio. + %1$s is unavailable. Using %2$s until it reconnects. + The source changed, but the latest watch progress could not be refreshed. + Keep saved titles in your Nuvio library. + Use your Trakt watchlist and personal lists. + Use your Simkl plan-to-watch and status lists. + Resume with progress stored by Nuvio Sync. + Resume with playback progress from Trakt. + Resume with playback progress from Simkl. + Use TMDB recommendations on details pages. + Use personalized related titles from Trakt. Connect Simkl Connected as %1$s Simkl user diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt index f44bb8d3..ef2e17d6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt @@ -230,11 +230,12 @@ internal fun SettingsSection( @Composable internal fun SettingsNavigationRow( title: String, - description: String, + description: String?, icon: ImageVector? = null, iconPainter: Painter? = null, enabled: Boolean = true, isTablet: Boolean, + trailingContent: (@Composable RowScope.() -> Unit)? = null, onClick: () -> Unit, ) { val tokens = MaterialTheme.nuvio @@ -294,15 +295,18 @@ internal fun SettingsNavigationRow( color = tokens.colors.textPrimary, fontWeight = FontWeight.Medium, ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - color = tokens.colors.textMuted, - modifier = Modifier.alpha(0.92f), - ) + if (!description.isNullOrBlank()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = tokens.colors.textMuted, + modifier = Modifier.alpha(0.92f), + ) + } } } + trailingContent?.invoke(this) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingAdaptivePicker.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingAdaptivePicker.kt new file mode 100644 index 00000000..2d1c0b06 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingAdaptivePicker.kt @@ -0,0 +1,255 @@ +package com.nuvio.app.features.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.nuvio.app.core.ui.NuvioBottomSheetDivider +import com.nuvio.app.core.ui.NuvioModalBottomSheet +import com.nuvio.app.core.ui.dismissNuvioBottomSheet +import com.nuvio.app.core.ui.nuvio +import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_close +import nuvio.composeapp.generated.resources.cd_selected +import org.jetbrains.compose.resources.stringResource + +internal data class TrackingPickerOption( + val value: T, + val title: String, + val description: String? = null, + val enabled: Boolean = true, + val unavailableReason: String? = null, +) + +@Composable +internal fun TrackingAdaptivePicker( + isTablet: Boolean, + title: String, + subtitle: String, + selectedValue: T, + options: List>, + onSelected: (T) -> Unit, + onDismiss: () -> Unit, +) { + if (isTablet) { + TrackingPickerDialog( + title = title, + subtitle = subtitle, + selectedValue = selectedValue, + options = options, + onSelected = onSelected, + onDismiss = onDismiss, + ) + } else { + TrackingPickerBottomSheet( + title = title, + subtitle = subtitle, + selectedValue = selectedValue, + options = options, + onSelected = onSelected, + onDismiss = onDismiss, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TrackingPickerBottomSheet( + title: String, + subtitle: String, + selectedValue: T, + options: List>, + onSelected: (T) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + + fun dismiss() { + scope.launch { + dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) + } + } + + NuvioModalBottomSheet( + onDismissRequest = ::dismiss, + sheetState = sheetState, + ) { + TrackingPickerContent( + title = title, + subtitle = subtitle, + selectedValue = selectedValue, + options = options, + isTablet = false, + onSelected = { value -> + onSelected(value) + dismiss() + }, + onDismiss = ::dismiss, + modifier = Modifier.navigationBarsPadding(), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TrackingPickerDialog( + title: String, + subtitle: String, + selectedValue: T, + options: List>, + onSelected: (T) -> Unit, + onDismiss: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier + .fillMaxWidth() + .widthIn(max = tokens.components.dialogMaxWidth), + shape = tokens.shapes.dialog, + color = tokens.colors.surfaceDialog, + ) { + TrackingPickerContent( + title = title, + subtitle = subtitle, + selectedValue = selectedValue, + options = options, + isTablet = true, + onSelected = { value -> + onSelected(value) + onDismiss() + }, + onDismiss = onDismiss, + ) + } + } +} + +@Composable +private fun TrackingPickerContent( + title: String, + subtitle: String, + selectedValue: T, + options: List>, + isTablet: Boolean, + onSelected: (T) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val tokens = MaterialTheme.nuvio + val horizontalPadding = if (isTablet) tokens.spacing.dialogPadding else tokens.spacing.screenHorizontal + Column( + modifier = modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + start = horizontalPadding, + end = horizontalPadding, + top = if (isTablet) tokens.spacing.dialogPadding else 12.dp, + bottom = 12.dp, + ), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = tokens.colors.textPrimary, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = tokens.colors.textMuted, + ) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 520.dp) + .verticalScroll(rememberScrollState()), + ) { + options.forEachIndexed { index, option -> + if (index > 0) { + NuvioBottomSheetDivider() + } + TrackingPickerOptionRow( + option = option, + selected = option.value == selectedValue, + isTablet = isTablet, + onClick = { onSelected(option.value) }, + ) + } + } + + if (isTablet) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding, vertical = 8.dp), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(Res.string.action_close)) + } + } + } + } +} + +@Composable +private fun TrackingPickerOptionRow( + option: TrackingPickerOption, + selected: Boolean, + isTablet: Boolean, + onClick: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + val description = listOfNotNull( + option.description?.takeIf(String::isNotBlank), + option.unavailableReason?.takeIf(String::isNotBlank), + ).joinToString("\n").takeIf(String::isNotBlank) + SettingsNavigationRow( + title = option.title, + description = description, + enabled = option.enabled, + isTablet = isTablet, + trailingContent = { + if (selected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = stringResource(Res.string.cd_selected), + tint = tokens.colors.accent, + modifier = Modifier.size(24.dp), + ) + } + }, + onClick = onClick, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingProviderCards.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingProviderCards.kt new file mode 100644 index 00000000..c552b8ba --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingProviderCards.kt @@ -0,0 +1,765 @@ +package com.nuvio.app.features.settings + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Sync +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nuvio.app.core.ui.NuvioLoadingIndicator +import com.nuvio.app.core.ui.NuvioTokens +import com.nuvio.app.core.ui.nuvio +import com.nuvio.app.features.simkl.SimklAuthError +import com.nuvio.app.features.simkl.SimklAuthRepository +import com.nuvio.app.features.simkl.SimklAuthUiState +import com.nuvio.app.features.simkl.SimklBrandAsset +import com.nuvio.app.features.simkl.SimklConnectionMode +import com.nuvio.app.features.simkl.SimklSyncRepository +import com.nuvio.app.features.simkl.simklBrandPainter +import com.nuvio.app.features.tracking.TrackingRefreshIntent +import com.nuvio.app.features.trakt.TraktAuthRepository +import com.nuvio.app.features.trakt.TraktAuthUiState +import com.nuvio.app.features.trakt.TraktBrandAsset +import com.nuvio.app.features.trakt.TraktConnectionMode +import com.nuvio.app.features.trakt.traktBrandPainter +import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_cancel +import nuvio.composeapp.generated.resources.settings_simkl_authorization_expired +import nuvio.composeapp.generated.resources.settings_simkl_authorization_revoked +import nuvio.composeapp.generated.resources.settings_simkl_connect +import nuvio.composeapp.generated.resources.settings_simkl_connected_as +import nuvio.composeapp.generated.resources.settings_simkl_connected_description +import nuvio.composeapp.generated.resources.settings_simkl_default_user +import nuvio.composeapp.generated.resources.settings_simkl_disconnect +import nuvio.composeapp.generated.resources.settings_simkl_finish_sign_in +import nuvio.composeapp.generated.resources.settings_simkl_invalid_callback +import nuvio.composeapp.generated.resources.settings_simkl_missing_credentials +import nuvio.composeapp.generated.resources.settings_simkl_open_login +import nuvio.composeapp.generated.resources.settings_simkl_sign_in_description +import nuvio.composeapp.generated.resources.settings_simkl_sign_in_failed +import nuvio.composeapp.generated.resources.settings_simkl_sync_info_action +import nuvio.composeapp.generated.resources.settings_simkl_sync_now +import nuvio.composeapp.generated.resources.settings_simkl_visit +import nuvio.composeapp.generated.resources.settings_tracking_approval_redirect +import nuvio.composeapp.generated.resources.settings_tracking_disconnect_description +import nuvio.composeapp.generated.resources.settings_tracking_disconnect_title +import nuvio.composeapp.generated.resources.settings_trakt_approval_redirect +import nuvio.composeapp.generated.resources.settings_trakt_connect +import nuvio.composeapp.generated.resources.settings_trakt_connected_as +import nuvio.composeapp.generated.resources.settings_trakt_default_user +import nuvio.composeapp.generated.resources.settings_trakt_disconnect +import nuvio.composeapp.generated.resources.settings_trakt_failed_open_browser +import nuvio.composeapp.generated.resources.settings_trakt_finish_sign_in +import nuvio.composeapp.generated.resources.settings_trakt_missing_credentials +import nuvio.composeapp.generated.resources.settings_trakt_open_login +import nuvio.composeapp.generated.resources.settings_trakt_save_actions_description +import nuvio.composeapp.generated.resources.settings_trakt_sign_in_description +import org.jetbrains.compose.resources.stringResource + +internal enum class TrackingBrand(val displayName: String) { + NUVIO("Nuvio"), + TRAKT("Trakt"), + SIMKL("Simkl"), + TMDB("TMDB"), +} + +internal enum class TrackingConnectionCardMode { + DISCONNECTED, + AWAITING_APPROVAL, + CONNECTED, +} + +internal fun isTrackingBrandAvailable( + brand: TrackingBrand, + traktConnected: Boolean, + simklConnected: Boolean, +): Boolean = when (brand) { + TrackingBrand.NUVIO, + TrackingBrand.TMDB, + -> true + TrackingBrand.TRAKT -> traktConnected + TrackingBrand.SIMKL -> simklConnected +} + +internal fun TraktConnectionMode.toTrackingConnectionCardMode(): TrackingConnectionCardMode = when (this) { + TraktConnectionMode.DISCONNECTED -> TrackingConnectionCardMode.DISCONNECTED + TraktConnectionMode.AWAITING_APPROVAL -> TrackingConnectionCardMode.AWAITING_APPROVAL + TraktConnectionMode.CONNECTED -> TrackingConnectionCardMode.CONNECTED +} + +internal fun SimklConnectionMode.toTrackingConnectionCardMode(): TrackingConnectionCardMode = when (this) { + SimklConnectionMode.DISCONNECTED -> TrackingConnectionCardMode.DISCONNECTED + SimklConnectionMode.AWAITING_APPROVAL -> TrackingConnectionCardMode.AWAITING_APPROVAL + SimklConnectionMode.CONNECTED -> TrackingConnectionCardMode.CONNECTED +} + +@Composable +internal fun TrackingProviderCards( + isTablet: Boolean, + traktUiState: TraktAuthUiState, + simklUiState: SimklAuthUiState, +) { + val syncState by remember { + SimklSyncRepository.ensureLoaded() + SimklSyncRepository.state + }.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + var showSyncInfo by rememberSaveable { mutableStateOf(false) } + + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val useTwoColumns = maxWidth >= 600.dp + if (useTwoColumns) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + TraktProviderCard( + uiState = traktUiState, + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + ) + SimklProviderCard( + uiState = simklUiState, + isSyncing = syncState.isLoading, + syncErrorMessage = syncState.errorMessage, + onSyncRequested = { + scope.launch { + SimklSyncRepository.refresh(TrackingRefreshIntent.USER_INITIATED) + } + }, + onInfoRequested = { showSyncInfo = true }, + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + ) + } + } else { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(if (isTablet) 16.dp else 12.dp), + ) { + TraktProviderCard( + uiState = traktUiState, + modifier = Modifier.fillMaxWidth(), + ) + SimklProviderCard( + uiState = simklUiState, + isSyncing = syncState.isLoading, + syncErrorMessage = syncState.errorMessage, + onSyncRequested = { + scope.launch { + SimklSyncRepository.refresh(TrackingRefreshIntent.USER_INITIATED) + } + }, + onInfoRequested = { showSyncInfo = true }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + + if (showSyncInfo) { + SimklSyncInfoDialog(onDismiss = { showSyncInfo = false }) + } +} + +@Composable +private fun TraktProviderCard( + uiState: TraktAuthUiState, + modifier: Modifier, +) { + TrackingProviderCard( + brand = TrackingBrand.TRAKT, + mode = uiState.mode.toTrackingConnectionCardMode(), + credentialsConfigured = uiState.credentialsConfigured, + isLoading = uiState.isLoading, + connectedLabel = stringResource( + Res.string.settings_trakt_connected_as, + uiState.username ?: stringResource(Res.string.settings_trakt_default_user), + ), + connectedDescription = stringResource(Res.string.settings_trakt_save_actions_description), + signInDescription = stringResource(Res.string.settings_trakt_sign_in_description), + finishSignInLabel = stringResource(Res.string.settings_trakt_finish_sign_in), + approvalDescription = stringResource(Res.string.settings_trakt_approval_redirect), + connectLabel = stringResource(Res.string.settings_trakt_connect), + openLoginLabel = stringResource(Res.string.settings_trakt_open_login), + disconnectLabel = stringResource(Res.string.settings_trakt_disconnect), + missingCredentialsMessage = stringResource(Res.string.settings_trakt_missing_credentials), + statusMessage = uiState.statusMessage.takeUnless { + uiState.mode == TraktConnectionMode.CONNECTED + }, + errorMessage = uiState.errorMessage, + onConnectRequested = TraktAuthRepository::onConnectRequested, + onResumeAuthorization = { + TraktAuthRepository.pendingAuthorizationUrl() + ?: TraktAuthRepository.onConnectRequested() + }, + onCancelAuthorization = TraktAuthRepository::onCancelAuthorization, + onDisconnect = TraktAuthRepository::onDisconnectRequested, + modifier = modifier, + ) +} + +@Composable +private fun SimklProviderCard( + uiState: SimklAuthUiState, + isSyncing: Boolean, + syncErrorMessage: String?, + onSyncRequested: () -> Unit, + onInfoRequested: () -> Unit, + modifier: Modifier, +) { + TrackingProviderCard( + brand = TrackingBrand.SIMKL, + mode = uiState.mode.toTrackingConnectionCardMode(), + credentialsConfigured = uiState.credentialsConfigured, + isLoading = uiState.isLoading, + connectedLabel = stringResource( + Res.string.settings_simkl_connected_as, + uiState.username ?: stringResource(Res.string.settings_simkl_default_user), + ), + connectedDescription = stringResource(Res.string.settings_simkl_connected_description), + signInDescription = stringResource(Res.string.settings_simkl_sign_in_description), + finishSignInLabel = stringResource(Res.string.settings_simkl_finish_sign_in), + approvalDescription = stringResource(Res.string.settings_tracking_approval_redirect), + connectLabel = stringResource(Res.string.settings_simkl_connect), + openLoginLabel = stringResource(Res.string.settings_simkl_open_login), + disconnectLabel = stringResource(Res.string.settings_simkl_disconnect), + syncLabel = stringResource(Res.string.settings_simkl_sync_now), + infoLabel = stringResource(Res.string.settings_simkl_sync_info_action), + isSyncing = isSyncing, + missingCredentialsMessage = stringResource(Res.string.settings_simkl_missing_credentials), + errorMessage = simklErrorMessage(uiState.error) ?: syncErrorMessage, + websiteLabel = stringResource(Res.string.settings_simkl_visit), + websiteUrl = SIMKL_WEBSITE_URL, + onConnectRequested = SimklAuthRepository::onConnectRequested, + onResumeAuthorization = { + SimklAuthRepository.pendingAuthorizationUrl() + ?: SimklAuthRepository.onConnectRequested() + }, + onCancelAuthorization = SimklAuthRepository::onCancelAuthorization, + onSyncRequested = onSyncRequested, + onInfoRequested = onInfoRequested, + onDisconnect = SimklAuthRepository::onDisconnectRequested, + modifier = modifier, + ) +} + +@Composable +private fun TrackingProviderCard( + brand: TrackingBrand, + mode: TrackingConnectionCardMode, + credentialsConfigured: Boolean, + isLoading: Boolean, + connectedLabel: String, + connectedDescription: String, + signInDescription: String, + finishSignInLabel: String, + approvalDescription: String, + connectLabel: String, + openLoginLabel: String, + disconnectLabel: String, + missingCredentialsMessage: String, + modifier: Modifier = Modifier, + syncLabel: String? = null, + infoLabel: String? = null, + isSyncing: Boolean = false, + statusMessage: String? = null, + errorMessage: String? = null, + websiteLabel: String? = null, + websiteUrl: String? = null, + onConnectRequested: () -> String?, + onResumeAuthorization: () -> String?, + onCancelAuthorization: () -> Unit, + onSyncRequested: (() -> Unit)? = null, + onInfoRequested: (() -> Unit)? = null, + onDisconnect: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + val uriHandler = LocalUriHandler.current + val failedOpenBrowserMessage = stringResource(Res.string.settings_trakt_failed_open_browser) + var browserError by rememberSaveable { mutableStateOf(false) } + var showDisconnectDialog by rememberSaveable { mutableStateOf(false) } + + fun openUrl(url: String?) { + if (url.isNullOrBlank()) return + browserError = false + runCatching { uriHandler.openUri(url) } + .onFailure { browserError = true } + } + + Box( + modifier = modifier + .clip(tokens.shapes.card) + .background(brand.cardBrush(), tokens.shapes.card) + .border( + width = tokens.borders.hairline, + color = Color.White.copy(alpha = 0.2f), + shape = tokens.shapes.card, + ), + ) { + TrackingBrandGlyph( + brand = brand, + contentDescription = null, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(12.dp) + .size(150.dp) + .alpha(0.08f), + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(if (mode == TrackingConnectionCardMode.CONNECTED) 20.dp else 18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + TrackingBrandWordmark( + brand = brand, + contentDescription = brand.displayName, + ) + + when (mode) { + TrackingConnectionCardMode.CONNECTED -> { + TrackingConnectedIdentity( + label = connectedLabel, + description = connectedDescription, + ) + if (syncLabel != null && onSyncRequested != null) { + TrackingBrandPrimaryButton( + label = syncLabel, + loading = isSyncing, + enabled = !isLoading && !isSyncing, + onClick = onSyncRequested, + showSyncIcon = true, + ) + } + } + + TrackingConnectionCardMode.AWAITING_APPROVAL -> { + Text( + text = finishSignInLabel, + style = MaterialTheme.typography.titleMedium, + color = Color.White, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = approvalDescription, + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.78f), + ) + TrackingBrandPrimaryButton( + label = openLoginLabel, + loading = isLoading, + enabled = !isLoading, + onClick = { openUrl(onResumeAuthorization()) }, + ) + OutlinedButton( + onClick = onCancelAuthorization, + enabled = !isLoading, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.44f)), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = Color.White, + disabledContentColor = Color.White.copy(alpha = 0.45f), + ), + ) { + Text(stringResource(Res.string.action_cancel)) + } + } + + TrackingConnectionCardMode.DISCONNECTED -> { + Text( + text = signInDescription, + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.82f), + ) + TrackingBrandPrimaryButton( + label = connectLabel, + loading = isLoading, + enabled = credentialsConfigured && !isLoading, + onClick = { openUrl(onConnectRequested()) }, + ) + if (!credentialsConfigured) { + TrackingBrandMessage( + text = missingCredentialsMessage, + isError = true, + ) + } + } + } + + statusMessage?.takeIf(String::isNotBlank)?.let { message -> + TrackingBrandMessage(text = message, isError = false) + } + errorMessage?.takeIf(String::isNotBlank)?.let { message -> + TrackingBrandMessage(text = message, isError = true) + } + if (browserError) { + TrackingBrandMessage(text = failedOpenBrowserMessage, isError = true) + } + + val hasWebsiteAction = !websiteLabel.isNullOrBlank() && !websiteUrl.isNullOrBlank() + val hasDisconnectAction = mode == TrackingConnectionCardMode.CONNECTED + val hasInfoAction = mode == TrackingConnectionCardMode.CONNECTED && + infoLabel != null && onInfoRequested != null + val footerActionCount = listOf( + hasWebsiteAction, + hasDisconnectAction, + hasInfoAction, + ).count { it } + if (footerActionCount > 0) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (hasWebsiteAction) { + TextButton( + onClick = { openUrl(websiteUrl) }, + modifier = if (footerActionCount > 1) Modifier.weight(0.95f) else Modifier, + contentPadding = PaddingValues(horizontal = 2.dp, vertical = 0.dp), + colors = ButtonDefaults.textButtonColors(contentColor = Color.White), + ) { + Text( + text = websiteLabel.orEmpty(), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (hasDisconnectAction) { + TextButton( + onClick = { showDisconnectDialog = true }, + modifier = if (footerActionCount > 1) Modifier.weight(1.05f) else Modifier, + enabled = !isLoading && !isSyncing, + contentPadding = PaddingValues(horizontal = 2.dp, vertical = 0.dp), + colors = ButtonDefaults.textButtonColors( + contentColor = Color.White.copy(alpha = 0.84f), + disabledContentColor = Color.White.copy(alpha = 0.38f), + ), + ) { + Text( + text = disconnectLabel, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (hasInfoAction) { + TextButton( + onClick = { onInfoRequested?.invoke() }, + modifier = if (footerActionCount > 1) Modifier.weight(1.55f) else Modifier, + contentPadding = PaddingValues(horizontal = 2.dp, vertical = 0.dp), + colors = ButtonDefaults.textButtonColors(contentColor = Color.White), + ) { + Text( + text = infoLabel.orEmpty(), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } + } + + if (showDisconnectDialog) { + TrackingDisconnectDialog( + brand = brand, + onConfirm = { + showDisconnectDialog = false + onDisconnect() + }, + onDismiss = { showDisconnectDialog = false }, + ) + } +} + +@Composable +private fun TrackingConnectedIdentity( + label: String, + description: String, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = Color.White, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.76f), + ) + } +} + +@Composable +private fun TrackingBrandPrimaryButton( + label: String, + loading: Boolean, + enabled: Boolean, + onClick: () -> Unit, + showSyncIcon: Boolean = false, +) { + Button( + onClick = onClick, + enabled = enabled, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = Color(0xFF171717), + disabledContainerColor = Color.White.copy(alpha = 0.34f), + disabledContentColor = Color.White.copy(alpha = 0.7f), + ), + ) { + when { + loading -> NuvioLoadingIndicator( + color = Color(0xFF171717), + modifier = Modifier.size(18.dp), + ) + showSyncIcon -> Icon( + imageVector = Icons.Rounded.Sync, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } + if (loading || showSyncIcon) { + Spacer(modifier = Modifier.width(8.dp)) + } + Text(label) + } +} + +@Composable +private fun TrackingBrandMessage( + text: String, + isError: Boolean, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = if (isError) TrackingErrorColor.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.1f), + shape = RoundedCornerShape(NuvioTokens.Radius.md), + ) { + Text( + text = text, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + style = MaterialTheme.typography.bodySmall, + color = if (isError) TrackingErrorColor else Color.White.copy(alpha = 0.82f), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TrackingDisconnectDialog( + brand: TrackingBrand, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier + .fillMaxWidth() + .widthIn(max = tokens.components.dialogMaxWidth), + shape = tokens.shapes.dialog, + color = tokens.colors.surfaceDialog, + ) { + Column( + modifier = Modifier.padding(tokens.spacing.dialogPadding), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(Res.string.settings_tracking_disconnect_title, brand.displayName), + style = MaterialTheme.typography.titleLarge, + color = tokens.colors.textPrimary, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(Res.string.settings_tracking_disconnect_description, brand.displayName), + style = MaterialTheme.typography.bodyMedium, + color = tokens.colors.textMuted, + ) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(Res.string.action_cancel)) + } + Button( + onClick = onConfirm, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(stringResource(Res.string.settings_trakt_disconnect)) + } + } + } + } + } +} + +@Composable +internal fun TrackingBrandGlyph( + brand: TrackingBrand, + modifier: Modifier = Modifier, + contentDescription: String? = null, +) { + when (brand) { + TrackingBrand.TRAKT -> Image( + painter = traktBrandPainter(TraktBrandAsset.Glyph), + contentDescription = contentDescription, + modifier = modifier, + contentScale = ContentScale.Fit, + ) + TrackingBrand.SIMKL -> Image( + painter = simklBrandPainter(SimklBrandAsset.Glyph), + contentDescription = contentDescription, + modifier = modifier, + contentScale = ContentScale.Fit, + ) + TrackingBrand.TMDB -> Image( + painter = integrationLogoPainter(IntegrationLogo.Tmdb), + contentDescription = contentDescription, + modifier = modifier, + contentScale = ContentScale.Fit, + ) + TrackingBrand.NUVIO -> Icon( + imageVector = Icons.Rounded.Sync, + contentDescription = contentDescription, + modifier = modifier, + tint = MaterialTheme.nuvio.colors.accent, + ) + } +} + +@Composable +private fun TrackingBrandWordmark( + brand: TrackingBrand, + contentDescription: String, +) { + val painter: Painter = when (brand) { + TrackingBrand.TRAKT -> traktBrandPainter(TraktBrandAsset.Wordmark) + TrackingBrand.SIMKL -> simklBrandPainter(SimklBrandAsset.Wordmark) + TrackingBrand.NUVIO, + TrackingBrand.TMDB, + -> return + } + Image( + painter = painter, + contentDescription = contentDescription, + modifier = when (brand) { + TrackingBrand.TRAKT -> Modifier + .width(86.dp) + .height(38.dp) + TrackingBrand.SIMKL -> Modifier + .width(124.dp) + .height(30.dp) + TrackingBrand.NUVIO, + TrackingBrand.TMDB, + -> Modifier + }, + contentScale = ContentScale.Fit, + alignment = Alignment.CenterStart, + ) +} + +private fun TrackingBrand.cardBrush(): Brush = when (this) { + TrackingBrand.TRAKT -> Brush.linearGradient( + colors = listOf(Color(0xFF7D279B), Color(0xFFD61F56), Color(0xFFF22125)), + ) + TrackingBrand.SIMKL -> Brush.linearGradient( + colors = listOf(Color(0xFF050505), Color(0xFF292929), Color(0xFF111111)), + ) + TrackingBrand.NUVIO, + TrackingBrand.TMDB, + -> Brush.linearGradient(colors = listOf(Color(0xFF242424), Color(0xFF111111))) +} + +@Composable +private fun simklErrorMessage(error: SimklAuthError?): String? = when (error) { + null, SimklAuthError.MISSING_CLIENT_ID -> null + SimklAuthError.INVALID_CALLBACK, + SimklAuthError.INVALID_CALLBACK_STATE, + -> stringResource(Res.string.settings_simkl_invalid_callback) + SimklAuthError.AUTHORIZATION_EXPIRED -> + stringResource(Res.string.settings_simkl_authorization_expired) + SimklAuthError.TOKEN_EXCHANGE_FAILED, + SimklAuthError.INVALID_TOKEN_RESPONSE, + -> stringResource(Res.string.settings_simkl_sign_in_failed) + SimklAuthError.AUTHORIZATION_REVOKED -> + stringResource(Res.string.settings_simkl_authorization_revoked) +} + +private val TrackingErrorColor = Color(0xFFFFDAD6) +private const val SIMKL_WEBSITE_URL = "https://simkl.com" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingSettingsPage.kt index f7e39bae..3d702ce9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingSettingsPage.kt @@ -1,29 +1,12 @@ package com.nuvio.app.features.settings -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Check -import androidx.compose.material.icons.rounded.Info -import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import com.nuvio.app.core.ui.NuvioLoadingIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -33,79 +16,52 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nuvio.app.core.ui.NuvioLoadingIndicator +import com.nuvio.app.core.ui.nuvio import com.nuvio.app.features.library.LibrarySourceMode import com.nuvio.app.features.profiles.ProfileRepository -import com.nuvio.app.features.simkl.SimklAuthError -import com.nuvio.app.features.simkl.SimklAuthRepository import com.nuvio.app.features.simkl.SimklAuthUiState import com.nuvio.app.features.simkl.SimklConnectionMode -import com.nuvio.app.features.simkl.SimklSyncRepository -import com.nuvio.app.features.trakt.TraktAuthRepository -import com.nuvio.app.features.trakt.TraktAuthUiState -import com.nuvio.app.features.trakt.TraktConnectionMode -import com.nuvio.app.features.trakt.TraktContinueWatchingDaysOptions -import com.nuvio.app.features.trakt.MoreLikeThisSourcePreference import com.nuvio.app.features.tracking.TrackingProviderId -import com.nuvio.app.features.tracking.TrackingRefreshIntent import com.nuvio.app.features.tracking.TrackingSettingsRepository import com.nuvio.app.features.tracking.TrackingSettingsUiState import com.nuvio.app.features.tracking.WatchProgressSource import com.nuvio.app.features.tracking.effectiveLibrarySourceMode import com.nuvio.app.features.tracking.effectiveWatchProgressSource +import com.nuvio.app.features.trakt.MoreLikeThisSourcePreference import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL +import com.nuvio.app.features.trakt.TraktAuthUiState +import com.nuvio.app.features.trakt.TraktConnectionMode +import com.nuvio.app.features.trakt.TraktContinueWatchingDaysOptions import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap import com.nuvio.app.features.watchprogress.WatchProgressSourceCoordinator import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.Res -import nuvio.composeapp.generated.resources.action_cancel -import nuvio.composeapp.generated.resources.settings_playback_dialog_close -import nuvio.composeapp.generated.resources.settings_trakt_approval_redirect -import nuvio.composeapp.generated.resources.settings_trakt_authentication +import nuvio.composeapp.generated.resources.action_retry +import nuvio.composeapp.generated.resources.settings_tracking_connect_first +import nuvio.composeapp.generated.resources.settings_tracking_data_sources +import nuvio.composeapp.generated.resources.settings_tracking_nuvio_library_description +import nuvio.composeapp.generated.resources.settings_tracking_nuvio_progress_description +import nuvio.composeapp.generated.resources.settings_tracking_progress_refresh_failed +import nuvio.composeapp.generated.resources.settings_tracking_services +import nuvio.composeapp.generated.resources.settings_tracking_simkl_library_description +import nuvio.composeapp.generated.resources.settings_tracking_simkl_progress_description +import nuvio.composeapp.generated.resources.settings_tracking_source_fallback +import nuvio.composeapp.generated.resources.settings_tracking_tmdb_recommendations_description +import nuvio.composeapp.generated.resources.settings_tracking_trakt_library_description +import nuvio.composeapp.generated.resources.settings_tracking_trakt_progress_description +import nuvio.composeapp.generated.resources.settings_tracking_trakt_recommendations_description +import nuvio.composeapp.generated.resources.settings_tracking_viewing_discovery import nuvio.composeapp.generated.resources.settings_trakt_comments import nuvio.composeapp.generated.resources.settings_trakt_comments_description -import nuvio.composeapp.generated.resources.settings_trakt_connect -import nuvio.composeapp.generated.resources.settings_trakt_connected_as -import nuvio.composeapp.generated.resources.settings_trakt_default_user -import nuvio.composeapp.generated.resources.settings_trakt_disconnect -import nuvio.composeapp.generated.resources.settings_trakt_failed_open_browser -import nuvio.composeapp.generated.resources.settings_trakt_features -import nuvio.composeapp.generated.resources.settings_trakt_finish_sign_in -import nuvio.composeapp.generated.resources.settings_trakt_intro_description -import nuvio.composeapp.generated.resources.settings_trakt_missing_credentials -import nuvio.composeapp.generated.resources.settings_trakt_open_login -import nuvio.composeapp.generated.resources.settings_trakt_save_actions_description -import nuvio.composeapp.generated.resources.settings_trakt_sign_in_description -import nuvio.composeapp.generated.resources.settings_tracking_approval_redirect -import nuvio.composeapp.generated.resources.settings_tracking_features -import nuvio.composeapp.generated.resources.settings_tracking_intro_description -import nuvio.composeapp.generated.resources.settings_simkl_authorization_expired -import nuvio.composeapp.generated.resources.settings_simkl_authorization_revoked -import nuvio.composeapp.generated.resources.settings_simkl_connect -import nuvio.composeapp.generated.resources.settings_simkl_connected_as -import nuvio.composeapp.generated.resources.settings_simkl_connected_description -import nuvio.composeapp.generated.resources.settings_simkl_default_user -import nuvio.composeapp.generated.resources.settings_simkl_disconnect -import nuvio.composeapp.generated.resources.settings_simkl_finish_sign_in -import nuvio.composeapp.generated.resources.settings_simkl_invalid_callback -import nuvio.composeapp.generated.resources.settings_simkl_missing_credentials -import nuvio.composeapp.generated.resources.settings_simkl_open_login -import nuvio.composeapp.generated.resources.settings_simkl_sign_in_description -import nuvio.composeapp.generated.resources.settings_simkl_sign_in_failed -import nuvio.composeapp.generated.resources.settings_simkl_sync_now -import nuvio.composeapp.generated.resources.settings_simkl_sync_info_action -import nuvio.composeapp.generated.resources.settings_simkl_visit -import nuvio.composeapp.generated.resources.tracking_library_source_simkl_selected import nuvio.composeapp.generated.resources.tracking_source_simkl import nuvio.composeapp.generated.resources.tracking_watch_progress_dialog_subtitle -import nuvio.composeapp.generated.resources.tracking_watch_progress_simkl_selected import nuvio.composeapp.generated.resources.trakt_all_history import nuvio.composeapp.generated.resources.trakt_continue_watching_subtitle import nuvio.composeapp.generated.resources.trakt_continue_watching_window @@ -115,25 +71,20 @@ import nuvio.composeapp.generated.resources.trakt_days_format import nuvio.composeapp.generated.resources.trakt_library_source_dialog_subtitle import nuvio.composeapp.generated.resources.trakt_library_source_dialog_title import nuvio.composeapp.generated.resources.trakt_library_source_nuvio -import nuvio.composeapp.generated.resources.trakt_library_source_nuvio_selected import nuvio.composeapp.generated.resources.trakt_library_source_subtitle import nuvio.composeapp.generated.resources.trakt_library_source_title import nuvio.composeapp.generated.resources.trakt_library_source_trakt -import nuvio.composeapp.generated.resources.trakt_library_source_trakt_selected import nuvio.composeapp.generated.resources.trakt_more_like_this_source_dialog_subtitle import nuvio.composeapp.generated.resources.trakt_more_like_this_source_dialog_title import nuvio.composeapp.generated.resources.trakt_more_like_this_source_subtitle import nuvio.composeapp.generated.resources.trakt_more_like_this_source_title import nuvio.composeapp.generated.resources.trakt_more_like_this_source_tmdb import nuvio.composeapp.generated.resources.trakt_more_like_this_source_trakt -import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_subtitle import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_title -import nuvio.composeapp.generated.resources.trakt_watch_progress_nuvio_selected import nuvio.composeapp.generated.resources.trakt_watch_progress_source_nuvio import nuvio.composeapp.generated.resources.trakt_watch_progress_source_trakt import nuvio.composeapp.generated.resources.trakt_watch_progress_subtitle import nuvio.composeapp.generated.resources.trakt_watch_progress_title -import nuvio.composeapp.generated.resources.trakt_watch_progress_trakt_selected import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.trackingSettingsContent( @@ -145,310 +96,438 @@ internal fun LazyListScope.trackingSettingsContent( onCommentsEnabledChange: (Boolean) -> Unit, ) { item { - SettingsGroup(isTablet = isTablet) { - TrackingIntro(isTablet = isTablet) + SettingsSection( + title = stringResource(Res.string.settings_tracking_services), + isTablet = isTablet, + ) { + TrackingProviderCards( + isTablet = isTablet, + traktUiState = traktUiState, + simklUiState = simklUiState, + ) } } item { SettingsSection( - title = "TRAKT", + title = stringResource(Res.string.settings_tracking_data_sources), isTablet = isTablet, ) { - SettingsGroup(isTablet = isTablet) { - TraktConnectionCard( - isTablet = isTablet, - uiState = traktUiState, - ) - } + TrackingDataSources( + isTablet = isTablet, + settingsUiState = settingsUiState, + traktConnected = traktUiState.mode == TraktConnectionMode.CONNECTED, + simklConnected = simklUiState.mode == SimklConnectionMode.CONNECTED, + ) } } item { SettingsSection( - title = "SIMKL", + title = stringResource(Res.string.settings_tracking_viewing_discovery), isTablet = isTablet, ) { - SettingsGroup(isTablet = isTablet) { - SimklConnectionCard( - isTablet = isTablet, - uiState = simklUiState, - ) - } - } - } - - item { - SettingsSection( - title = stringResource(Res.string.settings_tracking_features), - isTablet = isTablet, - ) { - SettingsGroup(isTablet = isTablet) { - TrackingFeatureRows( - isTablet = isTablet, - settingsUiState = settingsUiState, - traktConnected = traktUiState.mode == TraktConnectionMode.CONNECTED, - simklConnected = simklUiState.mode == SimklConnectionMode.CONNECTED, - commentsEnabled = commentsEnabled, - onCommentsEnabledChange = onCommentsEnabledChange, - ) - } + TrackingViewingAndDiscovery( + isTablet = isTablet, + settingsUiState = settingsUiState, + traktConnected = traktUiState.mode == TraktConnectionMode.CONNECTED, + commentsEnabled = commentsEnabled, + onCommentsEnabledChange = onCommentsEnabledChange, + ) } } } +private enum class TrackingDataPicker { + LIBRARY, + WATCH_PROGRESS, +} + @Composable -private fun TrackingFeatureRows( +private fun TrackingDataSources( isTablet: Boolean, settingsUiState: TrackingSettingsUiState, traktConnected: Boolean, simklConnected: Boolean, - commentsEnabled: Boolean, - onCommentsEnabledChange: (Boolean) -> Unit, ) { - var showLibrarySourceDialog by rememberSaveable { mutableStateOf(false) } - var showWatchProgressDialog by rememberSaveable { mutableStateOf(false) } - var showContinueWatchingWindowDialog by rememberSaveable { mutableStateOf(false) } - var showMoreLikeThisSourceDialog by rememberSaveable { mutableStateOf(false) } - var statusMessage by rememberSaveable { mutableStateOf(null) } + var activePickerName by rememberSaveable { mutableStateOf(null) } + val activePicker = activePickerName?.let(TrackingDataPicker::valueOf) val scope = rememberCoroutineScope() - + val transitionState by remember { + WatchProgressSourceCoordinator.ensureStarted() + WatchProgressSourceCoordinator.uiState + }.collectAsStateWithLifecycle() val connectedProviders = buildSet { if (traktConnected) add(TrackingProviderId.TRAKT) if (simklConnected) add(TrackingProviderId.SIMKL) } - val selectedLibrarySource = effectiveLibrarySourceMode(settingsUiState.librarySourceMode) { - providerId -> providerId in connectedProviders + val effectiveLibrarySource = effectiveLibrarySourceMode(settingsUiState.librarySourceMode) { provider -> + provider in connectedProviders } - val selectedWatchProgressSource = effectiveWatchProgressSource(settingsUiState.watchProgressSource) { - providerId -> providerId in connectedProviders + val effectiveProgressSource = effectiveWatchProgressSource(settingsUiState.watchProgressSource) { provider -> + provider in connectedProviders } - val availableLibrarySources = buildList { - add(LibrarySourceMode.LOCAL) - if (traktConnected) add(LibrarySourceMode.TRAKT) - if (simklConnected) add(LibrarySourceMode.SIMKL) + val libraryFallback = if (effectiveLibrarySource != settingsUiState.librarySourceMode) { + stringResource( + Res.string.settings_tracking_source_fallback, + librarySourceModeLabel(settingsUiState.librarySourceMode), + librarySourceModeLabel(effectiveLibrarySource), + ) + } else { + null } - val availableWatchProgressSources = buildList { - add(WatchProgressSource.NUVIO_SYNC) - if (traktConnected) add(WatchProgressSource.TRAKT) - if (simklConnected) add(WatchProgressSource.SIMKL) + val progressFallback = if (effectiveProgressSource != settingsUiState.watchProgressSource) { + stringResource( + Res.string.settings_tracking_source_fallback, + watchProgressSourceLabel(settingsUiState.watchProgressSource), + watchProgressSourceLabel(effectiveProgressSource), + ) + } else { + null } - val librarySourceValue = librarySourceModeLabel(selectedLibrarySource) - val watchProgressValue = watchProgressSourceLabel(selectedWatchProgressSource) - val continueWatchingWindowValue = continueWatchingDaysCapLabel(settingsUiState.continueWatchingDaysCap) - val moreLikeThisSourceValue = moreLikeThisSourceLabel(settingsUiState.moreLikeThisSource) - val traktProgressSelectedMessage = stringResource(Res.string.trakt_watch_progress_trakt_selected) - val simklProgressSelectedMessage = stringResource(Res.string.tracking_watch_progress_simkl_selected) - val nuvioProgressSelectedMessage = stringResource(Res.string.trakt_watch_progress_nuvio_selected) - val traktLibrarySelectedMessage = stringResource(Res.string.trakt_library_source_trakt_selected) - val simklLibrarySelectedMessage = stringResource(Res.string.tracking_library_source_simkl_selected) - val nuvioLibrarySelectedMessage = stringResource(Res.string.trakt_library_source_nuvio_selected) + SettingsGroup(isTablet = isTablet) { + TrackingPreferenceActionRow( + title = stringResource(Res.string.trakt_library_source_title), + description = stringResource(Res.string.trakt_library_source_subtitle), + value = librarySourceModeLabel(effectiveLibrarySource), + supportingMessage = libraryFallback, + isTablet = isTablet, + onClick = { activePickerName = TrackingDataPicker.LIBRARY.name }, + ) + SettingsGroupDivider(isTablet = isTablet) + TrackingPreferenceActionRow( + title = stringResource(Res.string.trakt_watch_progress_title), + description = stringResource(Res.string.trakt_watch_progress_subtitle), + value = watchProgressSourceLabel(effectiveProgressSource), + supportingMessage = progressFallback, + isLoading = transitionState.isRefreshing, + isTablet = isTablet, + onClick = { activePickerName = TrackingDataPicker.WATCH_PROGRESS.name }, + ) + if (transitionState.lastRefreshSucceeded == false && !transitionState.isRefreshing) { + SettingsGroupDivider(isTablet = isTablet) + TrackingInlineErrorRow( + isTablet = isTablet, + message = stringResource(Res.string.settings_tracking_progress_refresh_failed), + onRetry = { + scope.launch { + WatchProgressSourceCoordinator.refreshActiveSource(ProfileRepository.activeProfileId) + } + }, + ) + } + } - TrackingSettingsActionRow( - title = stringResource(Res.string.trakt_library_source_title), - description = stringResource(Res.string.trakt_library_source_subtitle), - value = librarySourceValue, - isTablet = isTablet, - onClick = { showLibrarySourceDialog = true }, + when (activePicker) { + TrackingDataPicker.LIBRARY -> TrackingAdaptivePicker( + isTablet = isTablet, + title = stringResource(Res.string.trakt_library_source_dialog_title), + subtitle = stringResource(Res.string.trakt_library_source_dialog_subtitle), + selectedValue = effectiveLibrarySource, + options = librarySourceOptions(traktConnected, simklConnected), + onSelected = TrackingSettingsRepository::setLibrarySourceMode, + onDismiss = { activePickerName = null }, + ) + TrackingDataPicker.WATCH_PROGRESS -> TrackingAdaptivePicker( + isTablet = isTablet, + title = stringResource(Res.string.trakt_watch_progress_dialog_title), + subtitle = stringResource(Res.string.tracking_watch_progress_dialog_subtitle), + selectedValue = effectiveProgressSource, + options = watchProgressSourceOptions(traktConnected, simklConnected), + onSelected = { source -> + scope.launch { + WatchProgressSourceCoordinator.selectSource( + profileId = ProfileRepository.activeProfileId, + source = source, + ) + } + }, + onDismiss = { activePickerName = null }, + ) + null -> Unit + } +} + +private enum class TrackingViewingPicker { + CONTINUE_WATCHING, + MORE_LIKE_THIS, +} + +@Composable +private fun TrackingViewingAndDiscovery( + isTablet: Boolean, + settingsUiState: TrackingSettingsUiState, + traktConnected: Boolean, + commentsEnabled: Boolean, + onCommentsEnabledChange: (Boolean) -> Unit, +) { + var activePickerName by rememberSaveable { mutableStateOf(null) } + val activePicker = activePickerName?.let(TrackingViewingPicker::valueOf) + val effectiveRecommendationsSource = effectiveTrackingRecommendationsSource( + source = settingsUiState.moreLikeThisSource, + traktConnected = traktConnected, ) - SettingsGroupDivider(isTablet = isTablet) - TrackingSettingsActionRow( - title = stringResource(Res.string.trakt_watch_progress_title), - description = stringResource(Res.string.trakt_watch_progress_subtitle), - value = watchProgressValue, - isTablet = isTablet, - onClick = { showWatchProgressDialog = true }, + val recommendationsFallback = if (effectiveRecommendationsSource != settingsUiState.moreLikeThisSource) { + stringResource( + Res.string.settings_tracking_source_fallback, + moreLikeThisSourceLabel(settingsUiState.moreLikeThisSource), + moreLikeThisSourceLabel(effectiveRecommendationsSource), + ) + } else { + null + } + val connectTraktFirst = stringResource( + Res.string.settings_tracking_connect_first, + TrackingBrand.TRAKT.displayName, ) - SettingsGroupDivider(isTablet = isTablet) - TrackingSettingsActionRow( - title = stringResource(Res.string.trakt_continue_watching_window), - description = stringResource(Res.string.trakt_continue_watching_subtitle), - value = continueWatchingWindowValue, - isTablet = isTablet, - onClick = { showContinueWatchingWindowDialog = true }, - ) - if (traktConnected) { + + SettingsGroup(isTablet = isTablet) { + TrackingPreferenceActionRow( + title = stringResource(Res.string.trakt_continue_watching_window), + description = stringResource(Res.string.trakt_continue_watching_subtitle), + value = continueWatchingDaysCapLabel(settingsUiState.continueWatchingDaysCap), + isTablet = isTablet, + onClick = { activePickerName = TrackingViewingPicker.CONTINUE_WATCHING.name }, + ) SettingsGroupDivider(isTablet = isTablet) SettingsSwitchRow( title = stringResource(Res.string.settings_trakt_comments), - description = stringResource(Res.string.settings_trakt_comments_description), + description = listOfNotNull( + stringResource(Res.string.settings_trakt_comments_description), + connectTraktFirst.takeUnless { traktConnected }, + ).joinToString("\n"), checked = commentsEnabled, + enabled = traktConnected, isTablet = isTablet, onCheckedChange = onCommentsEnabledChange, ) SettingsGroupDivider(isTablet = isTablet) - TrackingSettingsActionRow( + TrackingPreferenceActionRow( title = stringResource(Res.string.trakt_more_like_this_source_title), description = stringResource(Res.string.trakt_more_like_this_source_subtitle), - value = moreLikeThisSourceValue, + value = moreLikeThisSourceLabel(effectiveRecommendationsSource), + supportingMessage = recommendationsFallback, isTablet = isTablet, - onClick = { showMoreLikeThisSourceDialog = true }, + onClick = { activePickerName = TrackingViewingPicker.MORE_LIKE_THIS.name }, ) } - statusMessage?.takeIf { it.isNotBlank() }?.let { message -> - SettingsGroupDivider(isTablet = isTablet) - TrackingInfoRow( + + when (activePicker) { + TrackingViewingPicker.CONTINUE_WATCHING -> TrackingAdaptivePicker( isTablet = isTablet, - text = message, + title = stringResource(Res.string.trakt_cw_window_title), + subtitle = stringResource(Res.string.trakt_cw_window_subtitle), + selectedValue = normalizeTraktContinueWatchingDaysCap(settingsUiState.continueWatchingDaysCap), + options = continueWatchingOptions(), + onSelected = TrackingSettingsRepository::setContinueWatchingDaysCap, + onDismiss = { activePickerName = null }, ) - } - - if (showLibrarySourceDialog) { - LibrarySourceModeDialog( - selectedSource = selectedLibrarySource, - availableSources = availableLibrarySources, - onSourceSelected = { source -> - TrackingSettingsRepository.setLibrarySourceMode(source) - statusMessage = when (source) { - LibrarySourceMode.LOCAL -> nuvioLibrarySelectedMessage - LibrarySourceMode.TRAKT -> traktLibrarySelectedMessage - LibrarySourceMode.SIMKL -> simklLibrarySelectedMessage - } - showLibrarySourceDialog = false - }, - onDismiss = { showLibrarySourceDialog = false }, - ) - } - - if (showWatchProgressDialog) { - WatchProgressSourceDialog( - selectedSource = selectedWatchProgressSource, - availableSources = availableWatchProgressSources, - onSourceSelected = { source -> - scope.launch { - val result = WatchProgressSourceCoordinator.selectSource( - profileId = ProfileRepository.activeProfileId, - source = source, - ) - statusMessage = if (result.succeeded) { - when (result.requestedSource) { - WatchProgressSource.TRAKT -> traktProgressSelectedMessage - WatchProgressSource.SIMKL -> simklProgressSelectedMessage - WatchProgressSource.NUVIO_SYNC -> nuvioProgressSelectedMessage - } - } else { - null - } - } - showWatchProgressDialog = false - }, - onDismiss = { showWatchProgressDialog = false }, - ) - } - - if (showContinueWatchingWindowDialog) { - ContinueWatchingWindowDialog( - selectedDaysCap = settingsUiState.continueWatchingDaysCap, - onDaysCapSelected = { days -> - TrackingSettingsRepository.setContinueWatchingDaysCap(days) - showContinueWatchingWindowDialog = false - }, - onDismiss = { showContinueWatchingWindowDialog = false }, - ) - } - - if (showMoreLikeThisSourceDialog) { - MoreLikeThisSourceDialog( - selectedSource = settingsUiState.moreLikeThisSource, - onSourceSelected = { source -> - TrackingSettingsRepository.setMoreLikeThisSource(source) - showMoreLikeThisSourceDialog = false - }, - onDismiss = { showMoreLikeThisSourceDialog = false }, + TrackingViewingPicker.MORE_LIKE_THIS -> TrackingAdaptivePicker( + isTablet = isTablet, + title = stringResource(Res.string.trakt_more_like_this_source_dialog_title), + subtitle = stringResource(Res.string.trakt_more_like_this_source_dialog_subtitle), + selectedValue = effectiveRecommendationsSource, + options = recommendationsSourceOptions(traktConnected), + onSelected = TrackingSettingsRepository::setMoreLikeThisSource, + onDismiss = { activePickerName = null }, ) + null -> Unit } } @Composable -private fun TrackingSettingsActionRow( +private fun TrackingPreferenceActionRow( title: String, description: String, value: String, isTablet: Boolean, onClick: () -> Unit, + supportingMessage: String? = null, + isLoading: Boolean = false, ) { - val verticalPadding = if (isTablet) 16.dp else 14.dp - val horizontalPadding = if (isTablet) 20.dp else 16.dp - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = horizontalPadding, vertical = verticalPadding), - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(end = 12.dp) - .widthIn(max = if (isTablet) 560.dp else Dp.Unspecified), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = title, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Medium, - ) - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Text( - text = value, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Medium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } -} - -@Composable -private fun TrackingInfoRow( - isTablet: Boolean, - text: String, -) { - val horizontalPadding = if (isTablet) 20.dp else 16.dp - val verticalPadding = if (isTablet) 14.dp else 12.dp - - Text( - text = text, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = horizontalPadding, vertical = verticalPadding), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + val tokens = MaterialTheme.nuvio + SettingsNavigationRow( + title = title, + description = listOfNotNull( + description, + supportingMessage?.takeIf(String::isNotBlank), + ).joinToString("\n"), + isTablet = isTablet, + trailingContent = { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isLoading) { + NuvioLoadingIndicator( + color = tokens.colors.accent, + modifier = Modifier.size(16.dp), + ) + } + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = tokens.colors.accent, + fontWeight = FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + }, + onClick = onClick, ) } @Composable -private fun librarySourceModeLabel(source: LibrarySourceMode): String = - when (source) { - LibrarySourceMode.TRAKT -> stringResource(Res.string.trakt_library_source_trakt) - LibrarySourceMode.LOCAL -> stringResource(Res.string.trakt_library_source_nuvio) - LibrarySourceMode.SIMKL -> stringResource(Res.string.tracking_source_simkl) +private fun TrackingInlineErrorRow( + isTablet: Boolean, + message: String, + onRetry: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = if (isTablet) 20.dp else 16.dp, + vertical = if (isTablet) 14.dp else 12.dp, + ), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = message, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + color = tokens.colors.danger, + ) + TextButton(onClick = onRetry) { + Text(stringResource(Res.string.action_retry)) + } + } +} + +@Composable +private fun librarySourceOptions( + traktConnected: Boolean, + simklConnected: Boolean, +): List> { + val traktAvailable = isTrackingBrandAvailable(TrackingBrand.TRAKT, traktConnected, simklConnected) + val simklAvailable = isTrackingBrandAvailable(TrackingBrand.SIMKL, traktConnected, simklConnected) + return listOf( + TrackingPickerOption( + value = LibrarySourceMode.LOCAL, + title = stringResource(Res.string.trakt_library_source_nuvio), + description = stringResource(Res.string.settings_tracking_nuvio_library_description), + ), + TrackingPickerOption( + value = LibrarySourceMode.TRAKT, + title = stringResource(Res.string.trakt_library_source_trakt), + description = stringResource(Res.string.settings_tracking_trakt_library_description), + enabled = traktAvailable, + unavailableReason = trackingUnavailableReason(TrackingBrand.TRAKT, traktAvailable), + ), + TrackingPickerOption( + value = LibrarySourceMode.SIMKL, + title = stringResource(Res.string.tracking_source_simkl), + description = stringResource(Res.string.settings_tracking_simkl_library_description), + enabled = simklAvailable, + unavailableReason = trackingUnavailableReason(TrackingBrand.SIMKL, simklAvailable), + ), + ) +} + +@Composable +private fun watchProgressSourceOptions( + traktConnected: Boolean, + simklConnected: Boolean, +): List> { + val traktAvailable = isTrackingBrandAvailable(TrackingBrand.TRAKT, traktConnected, simklConnected) + val simklAvailable = isTrackingBrandAvailable(TrackingBrand.SIMKL, traktConnected, simklConnected) + return listOf( + TrackingPickerOption( + value = WatchProgressSource.NUVIO_SYNC, + title = stringResource(Res.string.trakt_watch_progress_source_nuvio), + description = stringResource(Res.string.settings_tracking_nuvio_progress_description), + ), + TrackingPickerOption( + value = WatchProgressSource.TRAKT, + title = stringResource(Res.string.trakt_watch_progress_source_trakt), + description = stringResource(Res.string.settings_tracking_trakt_progress_description), + enabled = traktAvailable, + unavailableReason = trackingUnavailableReason(TrackingBrand.TRAKT, traktAvailable), + ), + TrackingPickerOption( + value = WatchProgressSource.SIMKL, + title = stringResource(Res.string.tracking_source_simkl), + description = stringResource(Res.string.settings_tracking_simkl_progress_description), + enabled = simklAvailable, + unavailableReason = trackingUnavailableReason(TrackingBrand.SIMKL, simklAvailable), + ), + ) +} + +@Composable +private fun continueWatchingOptions(): List> = + TraktContinueWatchingDaysOptions.map { days -> + val normalizedDays = normalizeTraktContinueWatchingDaysCap(days) + TrackingPickerOption( + value = normalizedDays, + title = continueWatchingDaysCapLabel(normalizedDays), + ) } @Composable -private fun watchProgressSourceLabel(source: WatchProgressSource): String = - when (source) { - WatchProgressSource.TRAKT -> stringResource(Res.string.trakt_watch_progress_source_trakt) - WatchProgressSource.NUVIO_SYNC -> stringResource(Res.string.trakt_watch_progress_source_nuvio) - WatchProgressSource.SIMKL -> stringResource(Res.string.tracking_source_simkl) - } +private fun recommendationsSourceOptions( + traktConnected: Boolean, +): List> { + val traktAvailable = isTrackingBrandAvailable(TrackingBrand.TRAKT, traktConnected, simklConnected = false) + return listOf( + TrackingPickerOption( + value = MoreLikeThisSourcePreference.TMDB, + title = stringResource(Res.string.trakt_more_like_this_source_tmdb), + description = stringResource(Res.string.settings_tracking_tmdb_recommendations_description), + ), + TrackingPickerOption( + value = MoreLikeThisSourcePreference.TRAKT, + title = stringResource(Res.string.trakt_more_like_this_source_trakt), + description = stringResource(Res.string.settings_tracking_trakt_recommendations_description), + enabled = traktAvailable, + unavailableReason = trackingUnavailableReason(TrackingBrand.TRAKT, traktAvailable), + ), + ) +} @Composable -private fun moreLikeThisSourceLabel(source: MoreLikeThisSourcePreference): String = - when (source) { - MoreLikeThisSourcePreference.TRAKT -> stringResource(Res.string.trakt_more_like_this_source_trakt) - MoreLikeThisSourcePreference.TMDB -> stringResource(Res.string.trakt_more_like_this_source_tmdb) - } +private fun trackingUnavailableReason( + brand: TrackingBrand, + connected: Boolean, +): String? = if (connected) { + null +} else { + stringResource(Res.string.settings_tracking_connect_first, brand.displayName) +} + +@Composable +private fun librarySourceModeLabel(source: LibrarySourceMode): String = when (source) { + LibrarySourceMode.TRAKT -> stringResource(Res.string.trakt_library_source_trakt) + LibrarySourceMode.LOCAL -> stringResource(Res.string.trakt_library_source_nuvio) + LibrarySourceMode.SIMKL -> stringResource(Res.string.tracking_source_simkl) +} + +@Composable +private fun watchProgressSourceLabel(source: WatchProgressSource): String = when (source) { + WatchProgressSource.TRAKT -> stringResource(Res.string.trakt_watch_progress_source_trakt) + WatchProgressSource.NUVIO_SYNC -> stringResource(Res.string.trakt_watch_progress_source_nuvio) + WatchProgressSource.SIMKL -> stringResource(Res.string.tracking_source_simkl) +} + +@Composable +private fun moreLikeThisSourceLabel(source: MoreLikeThisSourcePreference): String = when (source) { + MoreLikeThisSourcePreference.TRAKT -> stringResource(Res.string.trakt_more_like_this_source_trakt) + MoreLikeThisSourcePreference.TMDB -> stringResource(Res.string.trakt_more_like_this_source_tmdb) +} @Composable private fun continueWatchingDaysCapLabel(daysCap: Int): String { @@ -460,599 +539,12 @@ private fun continueWatchingDaysCapLabel(daysCap: Int): String { } } -@Composable -@OptIn(ExperimentalMaterial3Api::class) -private fun LibrarySourceModeDialog( - selectedSource: LibrarySourceMode, - availableSources: List, - onSourceSelected: (LibrarySourceMode) -> Unit, - onDismiss: () -> Unit, -) { - BasicAlertDialog(onDismissRequest = onDismiss) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surface, - ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = stringResource(Res.string.trakt_library_source_dialog_title), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = stringResource(Res.string.trakt_library_source_dialog_subtitle), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - availableSources.forEach { source -> - TrackingDialogOption( - label = librarySourceModeLabel(source), - selected = source == selectedSource, - onClick = { onSourceSelected(source) }, - ) - } - } - - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = stringResource(Res.string.settings_playback_dialog_close), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -@Composable -@OptIn(ExperimentalMaterial3Api::class) -private fun WatchProgressSourceDialog( - selectedSource: WatchProgressSource, - availableSources: List, - onSourceSelected: (WatchProgressSource) -> Unit, - onDismiss: () -> Unit, -) { - BasicAlertDialog(onDismissRequest = onDismiss) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surface, - ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = stringResource(Res.string.trakt_watch_progress_dialog_title), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = stringResource(Res.string.tracking_watch_progress_dialog_subtitle), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - availableSources.forEach { source -> - TrackingDialogOption( - label = watchProgressSourceLabel(source), - selected = source == selectedSource, - onClick = { onSourceSelected(source) }, - ) - } - } - - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = stringResource(Res.string.settings_playback_dialog_close), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -@Composable -@OptIn(ExperimentalMaterial3Api::class) -private fun ContinueWatchingWindowDialog( - selectedDaysCap: Int, - onDaysCapSelected: (Int) -> Unit, - onDismiss: () -> Unit, -) { - val normalizedSelected = normalizeTraktContinueWatchingDaysCap(selectedDaysCap) - - BasicAlertDialog(onDismissRequest = onDismiss) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surface, - ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = stringResource(Res.string.trakt_cw_window_title), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = stringResource(Res.string.trakt_cw_window_subtitle), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - TraktContinueWatchingDaysOptions.forEach { days -> - val normalizedDays = normalizeTraktContinueWatchingDaysCap(days) - TrackingDialogOption( - label = continueWatchingDaysCapLabel(days), - selected = normalizedDays == normalizedSelected, - onClick = { onDaysCapSelected(days) }, - ) - } - } - - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = stringResource(Res.string.settings_playback_dialog_close), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -@Composable -@OptIn(ExperimentalMaterial3Api::class) -private fun MoreLikeThisSourceDialog( - selectedSource: MoreLikeThisSourcePreference, - onSourceSelected: (MoreLikeThisSourcePreference) -> Unit, - onDismiss: () -> Unit, -) { - BasicAlertDialog(onDismissRequest = onDismiss) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surface, - ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = stringResource(Res.string.trakt_more_like_this_source_dialog_title), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = stringResource(Res.string.trakt_more_like_this_source_dialog_subtitle), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - listOf(MoreLikeThisSourcePreference.TRAKT, MoreLikeThisSourcePreference.TMDB).forEach { source -> - TrackingDialogOption( - label = moreLikeThisSourceLabel(source), - selected = source == selectedSource, - onClick = { onSourceSelected(source) }, - ) - } - } - - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = stringResource(Res.string.settings_playback_dialog_close), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -@Composable -private fun TrackingDialogOption( - label: String, - selected: Boolean, - onClick: () -> Unit, -) { - val containerColor = if (selected) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) +internal fun effectiveTrackingRecommendationsSource( + source: MoreLikeThisSourcePreference, + traktConnected: Boolean, +): MoreLikeThisSourcePreference = + if (source == MoreLikeThisSourcePreference.TRAKT && !traktConnected) { + MoreLikeThisSourcePreference.TMDB } else { - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + source } - - Surface( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick), - shape = RoundedCornerShape(12.dp), - color = containerColor, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = label, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f), - ) - Box( - modifier = Modifier.size(24.dp), - contentAlignment = Alignment.Center, - ) { - if (selected) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - } - } - } - } -} - -@Composable -private fun TrackingIntro(isTablet: Boolean) { - val horizontalPadding = if (isTablet) 20.dp else 16.dp - val verticalPadding = if (isTablet) 18.dp else 16.dp - - Text( - text = stringResource(Res.string.settings_tracking_intro_description), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = horizontalPadding, vertical = verticalPadding), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) -} - -private enum class ConnectionCardMode { - DISCONNECTED, - AWAITING_APPROVAL, - CONNECTED, -} - -@Composable -private fun TraktConnectionCard( - isTablet: Boolean, - uiState: TraktAuthUiState, -) { - ProviderConnectionCard( - isTablet = isTablet, - mode = when (uiState.mode) { - TraktConnectionMode.DISCONNECTED -> ConnectionCardMode.DISCONNECTED - TraktConnectionMode.AWAITING_APPROVAL -> ConnectionCardMode.AWAITING_APPROVAL - TraktConnectionMode.CONNECTED -> ConnectionCardMode.CONNECTED - }, - credentialsConfigured = uiState.credentialsConfigured, - isLoading = uiState.isLoading, - connectedLabel = stringResource( - Res.string.settings_trakt_connected_as, - uiState.username ?: stringResource(Res.string.settings_trakt_default_user), - ), - connectedDescription = stringResource(Res.string.settings_trakt_save_actions_description), - signInDescription = stringResource(Res.string.settings_trakt_sign_in_description), - finishSignInLabel = stringResource(Res.string.settings_trakt_finish_sign_in), - approvalDescription = stringResource(Res.string.settings_trakt_approval_redirect), - connectLabel = stringResource(Res.string.settings_trakt_connect), - openLoginLabel = stringResource(Res.string.settings_trakt_open_login), - disconnectLabel = stringResource(Res.string.settings_trakt_disconnect), - missingCredentialsMessage = stringResource(Res.string.settings_trakt_missing_credentials), - statusMessage = uiState.statusMessage, - errorMessage = uiState.errorMessage, - onConnectRequested = TraktAuthRepository::onConnectRequested, - onResumeAuthorization = { - TraktAuthRepository.pendingAuthorizationUrl() - ?: TraktAuthRepository.onConnectRequested() - }, - onCancelAuthorization = TraktAuthRepository::onCancelAuthorization, - onDisconnect = TraktAuthRepository::onDisconnectRequested, - ) -} - -@Composable -private fun SimklConnectionCard( - isTablet: Boolean, - uiState: SimklAuthUiState, -) { - val syncState by remember { - SimklSyncRepository.ensureLoaded() - SimklSyncRepository.state - }.collectAsStateWithLifecycle() - val scope = rememberCoroutineScope() - var showSyncInfo by rememberSaveable { mutableStateOf(false) } - - ProviderConnectionCard( - isTablet = isTablet, - mode = when (uiState.mode) { - SimklConnectionMode.DISCONNECTED -> ConnectionCardMode.DISCONNECTED - SimklConnectionMode.AWAITING_APPROVAL -> ConnectionCardMode.AWAITING_APPROVAL - SimklConnectionMode.CONNECTED -> ConnectionCardMode.CONNECTED - }, - credentialsConfigured = uiState.credentialsConfigured, - isLoading = uiState.isLoading, - connectedLabel = stringResource( - Res.string.settings_simkl_connected_as, - uiState.username ?: stringResource(Res.string.settings_simkl_default_user), - ), - connectedDescription = stringResource(Res.string.settings_simkl_connected_description), - signInDescription = stringResource(Res.string.settings_simkl_sign_in_description), - finishSignInLabel = stringResource(Res.string.settings_simkl_finish_sign_in), - approvalDescription = stringResource(Res.string.settings_tracking_approval_redirect), - connectLabel = stringResource(Res.string.settings_simkl_connect), - openLoginLabel = stringResource(Res.string.settings_simkl_open_login), - disconnectLabel = stringResource(Res.string.settings_simkl_disconnect), - syncLabel = stringResource(Res.string.settings_simkl_sync_now), - infoLabel = stringResource(Res.string.settings_simkl_sync_info_action), - isSyncing = syncState.isLoading, - missingCredentialsMessage = stringResource(Res.string.settings_simkl_missing_credentials), - errorMessage = simklErrorMessage(uiState.error) ?: syncState.errorMessage, - websiteLabel = stringResource(Res.string.settings_simkl_visit), - websiteUrl = SIMKL_WEBSITE_URL, - onConnectRequested = SimklAuthRepository::onConnectRequested, - onResumeAuthorization = { - SimklAuthRepository.pendingAuthorizationUrl() - ?: SimklAuthRepository.onConnectRequested() - }, - onCancelAuthorization = SimklAuthRepository::onCancelAuthorization, - onSyncRequested = { - scope.launch { - SimklSyncRepository.refresh(TrackingRefreshIntent.USER_INITIATED) - } - }, - onInfoRequested = { showSyncInfo = true }, - onDisconnect = SimklAuthRepository::onDisconnectRequested, - ) - - if (showSyncInfo) { - SimklSyncInfoDialog(onDismiss = { showSyncInfo = false }) - } -} - -@Composable -private fun ProviderConnectionCard( - isTablet: Boolean, - mode: ConnectionCardMode, - credentialsConfigured: Boolean, - isLoading: Boolean, - connectedLabel: String, - connectedDescription: String, - signInDescription: String, - finishSignInLabel: String, - approvalDescription: String, - connectLabel: String, - openLoginLabel: String, - disconnectLabel: String, - syncLabel: String? = null, - infoLabel: String? = null, - isSyncing: Boolean = false, - missingCredentialsMessage: String, - statusMessage: String? = null, - errorMessage: String? = null, - websiteLabel: String? = null, - websiteUrl: String? = null, - onConnectRequested: () -> String?, - onResumeAuthorization: () -> String?, - onCancelAuthorization: () -> Unit, - onSyncRequested: (() -> Unit)? = null, - onInfoRequested: (() -> Unit)? = null, - onDisconnect: () -> Unit, -) { - val uriHandler = LocalUriHandler.current - val horizontalPadding = if (isTablet) 20.dp else 16.dp - val verticalPadding = if (isTablet) 18.dp else 16.dp - val failedOpenBrowserMessage = stringResource(Res.string.settings_trakt_failed_open_browser) - var browserError by rememberSaveable { mutableStateOf(null) } - - fun openUrl(url: String?) { - if (url.isNullOrBlank()) return - browserError = null - runCatching { uriHandler.openUri(url) } - .onFailure { error -> browserError = error.message ?: failedOpenBrowserMessage } - } - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = horizontalPadding, vertical = verticalPadding), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - when (mode) { - ConnectionCardMode.CONNECTED -> { - Text( - text = connectedLabel, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Medium, - ) - Text( - text = connectedDescription, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - if (syncLabel != null && onSyncRequested != null) { - Button( - onClick = onSyncRequested, - enabled = !isLoading && !isSyncing, - ) { - if (isSyncing) { - NuvioLoadingIndicator( - color = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(18.dp), - ) - } else { - Text(syncLabel) - } - } - } - if (infoLabel != null && onInfoRequested != null) { - TextButton(onClick = onInfoRequested) { - Icon( - imageVector = Icons.Rounded.Info, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.size(8.dp)) - Text(infoLabel) - } - } - Button( - onClick = onDisconnect, - enabled = !isLoading && !isSyncing, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurface, - ), - ) { - if (isLoading) { - NuvioLoadingIndicator( - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(18.dp), - ) - } else { - Text(disconnectLabel) - } - } - } - - ConnectionCardMode.AWAITING_APPROVAL -> { - Text( - text = finishSignInLabel, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Medium, - ) - Text( - text = approvalDescription, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Button( - onClick = { openUrl(onResumeAuthorization()) }, - enabled = !isLoading, - ) { - Text(openLoginLabel) - } - Button( - onClick = onCancelAuthorization, - enabled = !isLoading, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurface, - ), - ) { - Text(stringResource(Res.string.action_cancel)) - } - } - - ConnectionCardMode.DISCONNECTED -> { - Text( - text = signInDescription, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Button( - onClick = { openUrl(onConnectRequested()) }, - enabled = credentialsConfigured && !isLoading, - ) { - if (isLoading) { - NuvioLoadingIndicator( - color = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(18.dp), - ) - } else { - Text(connectLabel) - } - } - if (!credentialsConfigured) { - Text( - text = missingCredentialsMessage, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - } - } - - if (!websiteLabel.isNullOrBlank() && !websiteUrl.isNullOrBlank()) { - TextButton(onClick = { openUrl(websiteUrl) }) { - Text(websiteLabel) - } - } - statusMessage?.takeIf { it.isNotBlank() }?.let { message -> - Text( - text = message, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - errorMessage?.takeIf { it.isNotBlank() }?.let { message -> - Text( - text = message, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - browserError?.let { - Text( - text = failedOpenBrowserMessage, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - } -} - -@Composable -private fun simklErrorMessage(error: SimklAuthError?): String? = - when (error) { - null, SimklAuthError.MISSING_CLIENT_ID -> null - SimklAuthError.INVALID_CALLBACK, - SimklAuthError.INVALID_CALLBACK_STATE - -> stringResource(Res.string.settings_simkl_invalid_callback) - - SimklAuthError.AUTHORIZATION_EXPIRED -> - stringResource(Res.string.settings_simkl_authorization_expired) - - SimklAuthError.TOKEN_EXCHANGE_FAILED, - SimklAuthError.INVALID_TOKEN_RESPONSE - -> stringResource(Res.string.settings_simkl_sign_in_failed) - - SimklAuthError.AUTHORIZATION_REVOKED -> - stringResource(Res.string.settings_simkl_authorization_revoked) - } - -private const val SIMKL_WEBSITE_URL = "https://simkl.com" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.kt new file mode 100644 index 00000000..01d1a857 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.kt @@ -0,0 +1,12 @@ +package com.nuvio.app.features.simkl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter + +enum class SimklBrandAsset { + Glyph, + Wordmark, +} + +@Composable +expect fun simklBrandPainter(asset: SimklBrandAsset): Painter diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/settings/TrackingSettingsPresentationTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/settings/TrackingSettingsPresentationTest.kt new file mode 100644 index 00000000..189a5a59 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/settings/TrackingSettingsPresentationTest.kt @@ -0,0 +1,64 @@ +package com.nuvio.app.features.settings + +import com.nuvio.app.features.simkl.SimklConnectionMode +import com.nuvio.app.features.trakt.MoreLikeThisSourcePreference +import com.nuvio.app.features.trakt.TraktConnectionMode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TrackingSettingsPresentationTest { + @Test + fun `provider connection modes map to matching card modes`() { + assertEquals( + TrackingConnectionCardMode.DISCONNECTED, + TraktConnectionMode.DISCONNECTED.toTrackingConnectionCardMode(), + ) + assertEquals( + TrackingConnectionCardMode.AWAITING_APPROVAL, + TraktConnectionMode.AWAITING_APPROVAL.toTrackingConnectionCardMode(), + ) + assertEquals( + TrackingConnectionCardMode.CONNECTED, + TraktConnectionMode.CONNECTED.toTrackingConnectionCardMode(), + ) + assertEquals( + TrackingConnectionCardMode.DISCONNECTED, + SimklConnectionMode.DISCONNECTED.toTrackingConnectionCardMode(), + ) + assertEquals( + TrackingConnectionCardMode.AWAITING_APPROVAL, + SimklConnectionMode.AWAITING_APPROVAL.toTrackingConnectionCardMode(), + ) + assertEquals( + TrackingConnectionCardMode.CONNECTED, + SimklConnectionMode.CONNECTED.toTrackingConnectionCardMode(), + ) + } + + @Test + fun `remote brands are available only while their provider is connected`() { + assertTrue(isTrackingBrandAvailable(TrackingBrand.NUVIO, false, false)) + assertTrue(isTrackingBrandAvailable(TrackingBrand.TMDB, false, false)) + assertFalse(isTrackingBrandAvailable(TrackingBrand.TRAKT, false, true)) + assertTrue(isTrackingBrandAvailable(TrackingBrand.TRAKT, true, false)) + assertFalse(isTrackingBrandAvailable(TrackingBrand.SIMKL, true, false)) + assertTrue(isTrackingBrandAvailable(TrackingBrand.SIMKL, false, true)) + } + + @Test + fun `Trakt recommendations fall back visually without rewriting preference`() { + val stored = MoreLikeThisSourcePreference.TRAKT + + assertEquals( + MoreLikeThisSourcePreference.TMDB, + effectiveTrackingRecommendationsSource(stored, traktConnected = false), + ) + assertEquals( + MoreLikeThisSourcePreference.TRAKT, + effectiveTrackingRecommendationsSource(stored, traktConnected = true), + ) + assertEquals(MoreLikeThisSourcePreference.TRAKT, stored) + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.ios.kt new file mode 100644 index 00000000..156f6220 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.ios.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.simkl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.simkl_logo_glyph +import nuvio.composeapp.generated.resources.simkl_logo_wordmark +import org.jetbrains.compose.resources.painterResource + +@Composable +actual fun simklBrandPainter(asset: SimklBrandAsset): Painter = + when (asset) { + SimklBrandAsset.Glyph -> painterResource(Res.drawable.simkl_logo_glyph) + SimklBrandAsset.Wordmark -> painterResource(Res.drawable.simkl_logo_wordmark) + } From 4df54ab89f6d80863d5c21112e923045c5efb2bc Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:08:33 +0530 Subject: [PATCH 38/59] fix(simkl): confirm destructive library removals --- .../composeResources/values/strings.xml | 6 + .../commonMain/kotlin/com/nuvio/app/App.kt | 95 +++++++++++-- .../app/core/ui/PosterZoomActionOverlay.kt | 12 +- .../app/features/details/MetaDetailsScreen.kt | 90 ++++++++++-- .../app/features/library/LibraryRepository.kt | 131 +++++++++++++----- .../TrackingMembershipRemovalConfirmation.kt | 127 +++++++++++++++++ .../app/features/simkl/SimklLibraryAdapter.kt | 71 +++++++--- .../tracking/TrackingLibraryMembership.kt | 14 ++ .../app/features/tracking/TrackingReads.kt | 7 +- .../features/trakt/TraktLibraryRepository.kt | 10 -- .../trakt/TraktTrackingLibraryProvider.kt | 19 ++- .../features/simkl/SimklProjectionsTest.kt | 90 ++++++++++++ .../trakt/TraktTrackingLibraryProviderTest.kt | 22 +++ 13 files changed, 600 insertions(+), 94 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/TrackingMembershipRemovalConfirmation.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProviderTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 6a01bb0c..031b2e37 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -13,6 +13,7 @@ Play Previous Remove + Remove anyway Reorder Reset to Default Resume @@ -1467,6 +1468,11 @@ Failed to update Trakt lists Failed to update tracking lists %1$s placed this title in %2$s instead of %3$s + Remove from %1$s? + Removing “%1$s” from %2$s will also clear its %3$s there. This can’t be undone. + watched history + rating + watched history and rating %1$s • %2$s Update check failed Download failed diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 564002b6..50816838 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -107,6 +107,7 @@ import com.nuvio.app.core.ui.NuvioPosterZoomActionOverlay import com.nuvio.app.core.ui.PosterZoomAnchor import com.nuvio.app.core.ui.PosterZoomAnchorHolder import com.nuvio.app.core.ui.PosterZoomOverlayAction +import com.nuvio.app.core.ui.PosterZoomOverlayExitAnimation import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState import com.nuvio.app.core.ui.NuvioStatusModal @@ -162,6 +163,9 @@ import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.library.LibrarySection import com.nuvio.app.features.library.LibrarySortOption import com.nuvio.app.features.library.LibrarySourceMode +import com.nuvio.app.features.library.PendingTrackingMembershipRemoval +import com.nuvio.app.features.library.TrackingMembershipRemovalConfirmationHost +import com.nuvio.app.features.library.executeTrackingMembershipOperation import com.nuvio.app.features.library.showTrackingMembershipRewriteFeedback import com.nuvio.app.features.library.LibraryScreen import com.nuvio.app.features.library.toLibraryItem @@ -226,6 +230,8 @@ import com.nuvio.app.features.tracking.TrackingScrobbleCoordinator import com.nuvio.app.features.tracking.TrackingScrobbleEvent import com.nuvio.app.features.tracking.buildTrackingMediaReference import com.nuvio.app.features.tracking.TrackingLibraryTab +import com.nuvio.app.features.tracking.TrackingMembershipApplyResult +import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.tracking.toggleTrackingLibraryMembership import com.nuvio.app.features.updater.AppUpdaterHost import com.nuvio.app.features.updater.AppUpdaterPlatform @@ -820,6 +826,8 @@ private fun MainAppContent( var pickerMembership by remember { mutableStateOf>(emptyMap()) } var pickerPending by remember { mutableStateOf(false) } var pickerError by remember { mutableStateOf(null) } + var pendingTrackingRemoval by remember { mutableStateOf(null) } + val trackingListsUpdateFailedMessage = stringResource(Res.string.tracking_lists_update_failed) val addonsUiState by remember { AddonRepository.initialize() AddonRepository.uiState @@ -3300,8 +3308,6 @@ private fun MainAppContent( watchedKeys = watchedUiState.watchedKeys, item = preview, ) - // Remote-library items long-pressed outside the library open the list picker - // instead of removing, so only true removals disintegrate. val removesFromLibrary = isSaved && (posterActionTarget.libraryItem != null || !isRemoteLibrarySource) NuvioPosterZoomActionOverlay( @@ -3324,35 +3330,66 @@ private fun MainAppContent( stringResource(Res.string.hero_add_to_library) }, isDestructive = removesFromLibrary, + exitAnimation = if (removesFromLibrary && !isRemoteLibrarySource) { + PosterZoomOverlayExitAnimation.DISINTEGRATE + } else { + PosterZoomOverlayExitAnimation.COLLAPSE + }, onSelected = { val libraryItem = posterActionTarget.libraryItem ?: preview.toLibraryItem(savedAtEpochMs = 0L) if (posterActionTarget.libraryItem != null) { if (isRemoteLibrarySource) { coroutineScope.launch { - runCatching { - val listKey = posterActionTarget.libraryListKey + val listKey = posterActionTarget.libraryListKey + val removeMembership: suspend (Set) -> + TrackingMembershipApplyResult = { confirmedProviders -> if (listKey.isNullOrBlank()) { val currentMembership = LibraryRepository.getMembershipSnapshot(libraryItem) LibraryRepository.applyMembershipChanges( item = libraryItem, desiredMembership = currentMembership.mapValues { false }, + confirmedRemovalProviders = confirmedProviders, ) } else { - LibraryRepository.removeFromList(libraryItem, listKey) + LibraryRepository.removeFromList( + item = libraryItem, + listKey = listKey, + confirmedRemovalProviders = confirmedProviders, + ) } - }.onFailure { error -> - NuvioToastController.show( - error.message ?: getString(Res.string.tracking_lists_update_failed), - ) } + executeTrackingMembershipOperation( + operation = { removeMembership(emptySet()) }, + onSuccess = { result -> + if (result.requiresRemovalConfirmation) { + pendingTrackingRemoval = PendingTrackingMembershipRemoval( + itemTitle = libraryItem.name, + confirmations = result.requiredRemovalConfirmations, + retry = removeMembership, + onApplied = {}, + onFailure = { error -> + NuvioToastController.show( + error.message + ?: trackingListsUpdateFailedMessage, + ) + }, + ) + } + }, + onFailure = { error -> + NuvioToastController.show( + error.message ?: trackingListsUpdateFailedMessage, + ) + }, + ) } } else { LibraryRepository.remove(libraryItem.id) } } else { if (!isRemoteLibrarySource) { - LibraryRepository.toggleSaved(libraryItem) + LibraryRepository.toggleLocalSaved(libraryItem) } else { pickerItem = libraryItem pickerTitle = preview.name @@ -3519,24 +3556,52 @@ private fun MainAppContent( coroutineScope.launch { pickerPending = true pickerError = null - runCatching { + val desiredMembership = pickerMembership.toMap() + val applyMembership: suspend (Set) -> + TrackingMembershipApplyResult = { confirmedProviders -> LibraryRepository.applyMembershipChanges( item = item, - desiredMembership = pickerMembership, + desiredMembership = desiredMembership, + confirmedRemovalProviders = confirmedProviders, ) - }.onSuccess { result -> + } + val completeMembershipUpdate: suspend (TrackingMembershipApplyResult) -> Unit = { result -> showTrackingMembershipRewriteFeedback(result) showLibraryListPicker = false pickerItem = null pickerError = null - }.onFailure { error -> - pickerError = error.message ?: getString(Res.string.tracking_lists_update_failed) } + executeTrackingMembershipOperation( + operation = { applyMembership(emptySet()) }, + onSuccess = { result -> + if (result.requiresRemovalConfirmation) { + pendingTrackingRemoval = PendingTrackingMembershipRemoval( + itemTitle = item.name, + confirmations = result.requiredRemovalConfirmations, + retry = applyMembership, + onApplied = completeMembershipUpdate, + onFailure = { error -> + pickerError = error.message ?: trackingListsUpdateFailedMessage + }, + ) + } else { + completeMembershipUpdate(result) + } + }, + onFailure = { error -> + pickerError = error.message ?: trackingListsUpdateFailedMessage + }, + ) pickerPending = false } }, ) + TrackingMembershipRemovalConfirmationHost( + pending = pendingTrackingRemoval, + onPendingChange = { pendingTrackingRemoval = it }, + ) + NuvioStatusModal( title = stringResource(Res.string.app_exit_title), message = stringResource(Res.string.app_exit_message), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterZoomActionOverlay.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterZoomActionOverlay.kt index 9629e7f2..6bcaf17a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterZoomActionOverlay.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterZoomActionOverlay.kt @@ -87,10 +87,20 @@ object PosterZoomAnchorHolder { fun consume(): PosterZoomAnchor? = pending.also { pending = null } } +enum class PosterZoomOverlayExitAnimation { + COLLAPSE, + DISINTEGRATE, +} + class PosterZoomOverlayAction( val icon: ImageVector, val label: String, val isDestructive: Boolean = false, + val exitAnimation: PosterZoomOverlayExitAnimation = if (isDestructive) { + PosterZoomOverlayExitAnimation.DISINTEGRATE + } else { + PosterZoomOverlayExitAnimation.COLLAPSE + }, val onSelected: () -> Unit, ) @@ -181,7 +191,7 @@ fun NuvioPosterZoomActionOverlay( fun select(action: PosterZoomOverlayAction) { if (phase != PosterZoomPhase.Open) return - if (action.isDestructive) { + if (action.exitAnimation == PosterZoomOverlayExitAnimation.DISINTEGRATE) { phase = PosterZoomPhase.Disintegrating hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) action.onSelected() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 41f45319..946184ff 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -80,6 +80,7 @@ import com.nuvio.app.core.i18n.localizedSeasonEpisodeCode import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.NuvioCardDepthSurface import com.nuvio.app.core.ui.NuvioPosterZoomActionOverlay +import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.core.ui.PosterZoomAnchor import com.nuvio.app.core.ui.PosterZoomAnchorHolder import com.nuvio.app.core.ui.PosterZoomOverlayAction @@ -106,6 +107,9 @@ import com.nuvio.app.features.details.components.SeasonWatchedActionSheet import com.nuvio.app.features.details.components.TrailerPlayerPopup import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.library.LibraryRepository +import com.nuvio.app.features.library.PendingTrackingMembershipRemoval +import com.nuvio.app.features.library.TrackingMembershipRemovalConfirmationHost +import com.nuvio.app.features.library.executeTrackingMembershipOperation import com.nuvio.app.features.library.showTrackingMembershipRewriteFeedback import com.nuvio.app.features.library.toLibraryItem import com.nuvio.app.features.player.PlayerSettingsRepository @@ -118,6 +122,7 @@ import com.nuvio.app.features.trakt.TraktCommentsRepository import com.nuvio.app.features.trakt.TraktCommentsSettings import com.nuvio.app.features.trakt.TraktConnectionMode import com.nuvio.app.features.tracking.TrackingLibraryTab +import com.nuvio.app.features.tracking.TrackingMembershipApplyResult import com.nuvio.app.features.tracking.toggleTrackingLibraryMembership import com.nuvio.app.features.tracking.TrackingSettingsRepository import com.nuvio.app.features.tracking.TrackingProviderId @@ -224,6 +229,10 @@ fun MetaDetailsScreen( var pickerMembership by remember(type, id) { mutableStateOf>(emptyMap()) } var pickerPending by remember(type, id) { mutableStateOf(false) } var pickerError by remember(type, id) { mutableStateOf(null) } + var pendingTrackingRemoval by remember(type, id) { + mutableStateOf(null) + } + val trackingListsUpdateFailedMessage = stringResource(Res.string.tracking_lists_update_failed) var episodeImdbRatings by remember(type, id) { mutableStateOf, Double>>(emptyMap()) } var deferredMetaWorkAllowed by remember(type, id) { mutableStateOf(false) } @@ -507,9 +516,44 @@ fun MetaDetailsScreen( Unit } } - val toggleSaved = remember(meta) { + val toggleSaved = remember(meta, trackingListsUpdateFailedMessage) { { - LibraryRepository.toggleSaved(meta.toLibraryItem(savedAtEpochMs = 0L)) + val item = meta.toLibraryItem(savedAtEpochMs = 0L) + detailsScope.launch { + val toggleMembership: suspend (Set) -> + TrackingMembershipApplyResult = { confirmedProviders -> + LibraryRepository.toggleSaved( + item = item, + confirmedRemovalProviders = confirmedProviders, + ) + } + executeTrackingMembershipOperation( + operation = { toggleMembership(emptySet()) }, + onSuccess = { result -> + if (result.requiresRemovalConfirmation) { + pendingTrackingRemoval = PendingTrackingMembershipRemoval( + itemTitle = item.name, + confirmations = result.requiredRemovalConfirmations, + retry = toggleMembership, + onApplied = ::showTrackingMembershipRewriteFeedback, + onFailure = { error -> + NuvioToastController.show( + error.message ?: trackingListsUpdateFailedMessage, + ) + }, + ) + } else { + showTrackingMembershipRewriteFeedback(result) + } + }, + onFailure = { error -> + NuvioToastController.show( + error.message ?: trackingListsUpdateFailedMessage, + ) + }, + ) + } + Unit } } val toggleWatched = remember(metaPreview) { @@ -1305,22 +1349,52 @@ fun MetaDetailsScreen( detailsScope.launch { pickerPending = true pickerError = null - runCatching { + val item = meta.toLibraryItem(savedAtEpochMs = 0L) + val desiredMembership = pickerMembership.toMap() + val applyMembership: suspend (Set) -> + TrackingMembershipApplyResult = { confirmedProviders -> LibraryRepository.applyMembershipChanges( - item = meta.toLibraryItem(savedAtEpochMs = 0L), - desiredMembership = pickerMembership, + item = item, + desiredMembership = desiredMembership, + confirmedRemovalProviders = confirmedProviders, ) - }.onSuccess { result -> + } + val completeMembershipUpdate: suspend (TrackingMembershipApplyResult) -> Unit = { result -> showTrackingMembershipRewriteFeedback(result) showLibraryListPicker = false - }.onFailure { error -> - pickerError = error.message ?: getString(Res.string.tracking_lists_update_failed) } + executeTrackingMembershipOperation( + operation = { applyMembership(emptySet()) }, + onSuccess = { result -> + if (result.requiresRemovalConfirmation) { + pendingTrackingRemoval = PendingTrackingMembershipRemoval( + itemTitle = item.name, + confirmations = result.requiredRemovalConfirmations, + retry = applyMembership, + onApplied = completeMembershipUpdate, + onFailure = { error -> + pickerError = error.message + ?: trackingListsUpdateFailedMessage + }, + ) + } else { + completeMembershipUpdate(result) + } + }, + onFailure = { error -> + pickerError = error.message ?: trackingListsUpdateFailedMessage + }, + ) pickerPending = false } }, ) + TrackingMembershipRemovalConfirmationHost( + pending = pendingTrackingRemoval, + onPendingChange = { pendingTrackingRemoval = it }, + ) + selectedComment?.let { comment -> val commentIndex = comments.indexOfFirst { it.id == comment.id }.coerceAtLeast(0) CommentDetailSheet( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt index 7cf30609..44c3d896 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt @@ -6,7 +6,6 @@ import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.core.sync.putSyncOriginClientId import com.nuvio.app.core.tracking.ensureTrackingProvidersRegistered -import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.tracking.TrackingLibraryProvider @@ -14,6 +13,7 @@ import com.nuvio.app.features.tracking.TrackingLibraryTab import com.nuvio.app.features.tracking.TrackingLibraryTabKind import com.nuvio.app.features.tracking.TrackingMembershipApplyResult import com.nuvio.app.features.tracking.TrackingMembershipResolution +import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.tracking.TrackingProviderRegistry import com.nuvio.app.features.tracking.TrackingRefreshIntent import com.nuvio.app.features.tracking.TrackingSettingsRepository @@ -51,7 +51,6 @@ import kotlinx.serialization.json.put import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.library_local_tab_title import nuvio.composeapp.generated.resources.library_other -import nuvio.composeapp.generated.resources.tracking_lists_update_failed import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.getString @@ -272,30 +271,37 @@ object LibraryRepository { private fun isActiveOperation(token: LibraryProfileToken): Boolean = localState.isCurrent(token) && ProfileRepository.activeProfileId == token.profileId - fun toggleSaved(item: LibraryItem) { + suspend fun toggleSaved( + item: LibraryItem, + confirmedRemovalProviders: Set = emptySet(), + ): TrackingMembershipApplyResult { ensureLoaded() activeLibraryProvider()?.let { provider -> - val profileId = localState.snapshot().token.profileId + val providerMembership = provider.membership(item) + val desiredMembership = provider.toggledDefaultMembership(providerMembership) log.i { "toggleSaved routed to ${provider.providerId.storageId} library source " + - "item=${item.id} type=${item.type} profile=$profileId" + "item=${item.id} type=${item.type} profile=${localState.snapshot().token.profileId}" } - syncScope.launch { - runCatching { provider.toggleDefaultMembership(profileId, item) } - .onFailure { error -> - if (error is CancellationException) throw error - log.e(error) { "Failed to toggle ${provider.providerId.storageId} default library membership" } - NuvioToastController.show( - error.message?.takeIf(String::isNotBlank) - ?: getString(Res.string.tracking_lists_update_failed), - ) - } - publish() - } - return + return applyMembershipChanges( + item = item, + desiredMembership = desiredMembership, + confirmedRemovalProviders = confirmedRemovalProviders, + targetProviderIds = setOf(provider.providerId), + updateLocal = false, + ) } + return toggleLocalSavedInternal(item) + } + + fun toggleLocalSaved(item: LibraryItem) { + ensureLoaded() + toggleLocalSavedInternal(item) + } + + private fun toggleLocalSavedInternal(item: LibraryItem): TrackingMembershipApplyResult { val result = localState.toggle( item.copy(savedAtEpochMs = LibraryClock.nowEpochMs()), ) @@ -313,6 +319,7 @@ object LibraryRepository { persist(result.snapshot) publish() pushToServer(result.snapshot) + return TrackingMembershipApplyResult() } fun save(item: LibraryItem) { @@ -392,17 +399,50 @@ object LibraryRepository { suspend fun applyMembershipChanges( item: LibraryItem, desiredMembership: Map, + confirmedRemovalProviders: Set = emptySet(), + ): TrackingMembershipApplyResult = applyMembershipChanges( + item = item, + desiredMembership = desiredMembership, + confirmedRemovalProviders = confirmedRemovalProviders, + targetProviderIds = null, + updateLocal = true, + ) + + private suspend fun applyMembershipChanges( + item: LibraryItem, + desiredMembership: Map, + confirmedRemovalProviders: Set, + targetProviderIds: Set?, + updateLocal: Boolean, ): TrackingMembershipApplyResult { ensureLoaded() val localDesired = desiredMembership[LOCAL_LIBRARY_LIST_KEY] == true val currentlyInLocal = localState.contains(item.id, item.type) val profileId = localState.snapshot().token.profileId + val providerChanges = TrackingProviderRegistry.connectedLibraryProviders() + .filter { provider -> targetProviderIds == null || provider.providerId in targetProviderIds } + .mapNotNull { provider -> + val providerListKeys = provider.snapshot().tabs.mapTo(mutableSetOf(), TrackingLibraryTab::key) + val providerMembership = desiredMembership.filterKeys(providerListKeys::contains) + providerMembership.takeIf { membership -> membership.isNotEmpty() }?.let { membership -> + provider to membership + } + } + val requiredConfirmations = providerChanges.mapNotNull { (provider, providerMembership) -> + provider.membershipRemovalConfirmation(item, providerMembership) + ?.takeUnless { confirmation -> confirmation.providerId in confirmedRemovalProviders } + } log.i { "Applying library membership item=${item.id} type=${item.type} profile=$profileId " + "localDesired=$localDesired currentlyInLocal=$currentlyInLocal " + "connectedProviders=${TrackingProviderRegistry.connectedProviderIdsSnapshot()}" } - if (localDesired != currentlyInLocal) { + if (requiredConfirmations.isNotEmpty()) { + return TrackingMembershipApplyResult( + requiredRemovalConfirmations = requiredConfirmations, + ) + } + if (updateLocal && localDesired != currentlyInLocal) { if (localDesired) { save(item) } else { @@ -412,22 +452,19 @@ object LibraryRepository { var firstFailure: Throwable? = null val resolutions = mutableListOf() - TrackingProviderRegistry.connectedLibraryProviders().forEach { provider -> - val providerListKeys = provider.snapshot().tabs.mapTo(mutableSetOf(), TrackingLibraryTab::key) - val providerMembership = desiredMembership.filterKeys(providerListKeys::contains) - if (providerMembership.isNotEmpty()) { - try { - provider.applyMembership( - profileId = profileId, - item = item, - desiredMembership = providerMembership, - )?.let(resolutions::add) - } catch (error: CancellationException) { - throw error - } catch (error: Throwable) { - if (firstFailure == null) firstFailure = error - log.e(error) { "Failed to update ${provider.providerId.storageId} library membership" } - } + providerChanges.forEach { (provider, providerMembership) -> + try { + provider.applyMembership( + profileId = profileId, + item = item, + desiredMembership = providerMembership, + destructiveRemovalConfirmed = provider.providerId in confirmedRemovalProviders, + )?.let(resolutions::add) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + if (firstFailure == null) firstFailure = error + log.e(error) { "Failed to update ${provider.providerId.storageId} library membership" } } } publish() @@ -435,12 +472,30 @@ object LibraryRepository { return TrackingMembershipApplyResult(resolutions = resolutions) } - suspend fun removeFromList(item: LibraryItem, listKey: String) { + suspend fun removeFromList( + item: LibraryItem, + listKey: String, + confirmedRemovalProviders: Set = emptySet(), + ): TrackingMembershipApplyResult { + ensureLoaded() + val targetProvider = TrackingProviderRegistry.connectedLibraryProviders() + .firstOrNull { provider -> provider.snapshot().tabs.any { tab -> tab.key == listKey } } + val currentMembership = if (listKey == LOCAL_LIBRARY_LIST_KEY) { + mapOf(LOCAL_LIBRARY_LIST_KEY to localState.contains(item.id, item.type)) + } else { + targetProvider?.membership(item).orEmpty() + } val desiredMembership = libraryMembershipWithRemovedList( - currentMembership = getMembershipSnapshot(item), + currentMembership = currentMembership, listKey = listKey, ) - applyMembershipChanges(item, desiredMembership) + return applyMembershipChanges( + item = item, + desiredMembership = desiredMembership, + confirmedRemovalProviders = confirmedRemovalProviders, + targetProviderIds = targetProvider?.let { provider -> setOf(provider.providerId) }.orEmpty(), + updateLocal = listKey == LOCAL_LIBRARY_LIST_KEY, + ) } private fun pushToServer(snapshot: LibraryLocalSnapshot) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/TrackingMembershipRemovalConfirmation.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/TrackingMembershipRemovalConfirmation.kt new file mode 100644 index 00000000..b80929b9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/TrackingMembershipRemovalConfirmation.kt @@ -0,0 +1,127 @@ +package com.nuvio.app.features.library + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import com.nuvio.app.core.ui.NuvioStatusModal +import com.nuvio.app.features.tracking.TrackingMembershipApplyResult +import com.nuvio.app.features.tracking.TrackingMembershipRemovalConfirmation +import com.nuvio.app.features.tracking.TrackingMembershipRemovalImpact +import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.TrackingProviderRegistry +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_cancel +import nuvio.composeapp.generated.resources.action_remove_anyway +import nuvio.composeapp.generated.resources.tracking_remove_confirmation_message +import nuvio.composeapp.generated.resources.tracking_remove_confirmation_title +import nuvio.composeapp.generated.resources.tracking_removal_impact_history +import nuvio.composeapp.generated.resources.tracking_removal_impact_history_and_rating +import nuvio.composeapp.generated.resources.tracking_removal_impact_rating +import org.jetbrains.compose.resources.stringResource + +class PendingTrackingMembershipRemoval( + val itemTitle: String, + val confirmations: List, + val retry: suspend (Set) -> TrackingMembershipApplyResult, + val onApplied: suspend (TrackingMembershipApplyResult) -> Unit, + val onFailure: suspend (Throwable) -> Unit, +) + +suspend fun executeTrackingMembershipOperation( + operation: suspend () -> TrackingMembershipApplyResult, + onSuccess: suspend (TrackingMembershipApplyResult) -> Unit, + onFailure: suspend (Throwable) -> Unit, +) { + try { + onSuccess(operation()) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + onFailure(error) + } +} + +@Composable +fun TrackingMembershipRemovalConfirmationHost( + pending: PendingTrackingMembershipRemoval?, + onPendingChange: (PendingTrackingMembershipRemoval?) -> Unit, +) { + var isBusy by remember(pending) { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val confirmations = pending?.confirmations.orEmpty() + val providerNames = confirmations + .map { confirmation -> + TrackingProviderRegistry.authProvider(confirmation.providerId) + ?.descriptor + ?.displayName + ?: confirmation.providerId.storageId.replaceFirstChar(Char::uppercase) + } + .distinct() + .joinToString() + val impacts = confirmations.flatMapTo(linkedSetOf()) { confirmation -> confirmation.impacts } + val impactLabel = when (impacts) { + setOf(TrackingMembershipRemovalImpact.WATCHED_HISTORY) -> + stringResource(Res.string.tracking_removal_impact_history) + + setOf(TrackingMembershipRemovalImpact.RATING) -> + stringResource(Res.string.tracking_removal_impact_rating) + + else -> stringResource(Res.string.tracking_removal_impact_history_and_rating) + } + + NuvioStatusModal( + title = stringResource(Res.string.tracking_remove_confirmation_title, providerNames), + message = stringResource( + Res.string.tracking_remove_confirmation_message, + pending?.itemTitle.orEmpty(), + providerNames, + impactLabel, + ), + isVisible = pending != null, + isBusy = isBusy, + confirmText = stringResource(Res.string.action_remove_anyway), + dismissText = stringResource(Res.string.action_cancel), + onConfirm = { + val request = pending ?: return@NuvioStatusModal + if (isBusy) return@NuvioStatusModal + isBusy = true + scope.launch { + try { + val confirmedProviders = request.confirmations.mapTo(linkedSetOf()) { confirmation -> + confirmation.providerId + } + val result = request.retry(confirmedProviders) + if (result.requiresRemovalConfirmation) { + onPendingChange( + PendingTrackingMembershipRemoval( + itemTitle = request.itemTitle, + confirmations = result.requiredRemovalConfirmations, + retry = request.retry, + onApplied = request.onApplied, + onFailure = request.onFailure, + ), + ) + } else { + onPendingChange(null) + request.onApplied(result) + } + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + onPendingChange(null) + request.onFailure(error) + } finally { + isBusy = false + } + } + }, + onDismiss = { + if (!isBusy) onPendingChange(null) + }, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt index 28ace3bb..d10b06ee 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt @@ -7,6 +7,8 @@ import com.nuvio.app.features.tracking.TrackingLibraryProvider import com.nuvio.app.features.tracking.TrackingLibrarySnapshot import com.nuvio.app.features.tracking.TrackingLibraryTab import com.nuvio.app.features.tracking.TrackingLibraryTabKind +import com.nuvio.app.features.tracking.TrackingMembershipRemovalConfirmation +import com.nuvio.app.features.tracking.TrackingMembershipRemovalImpact import com.nuvio.app.features.tracking.TrackingMembershipResolution import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.tracking.TrackingRefreshIntent @@ -68,6 +70,7 @@ object SimklLibraryRepository { profileId: Int, item: LibraryItem, desiredMembership: Map, + destructiveRemovalConfirmed: Boolean = false, ): TrackingMembershipResolution? { if (profileId != ProfileRepository.activeProfileId) return null ensureLoaded() @@ -98,7 +101,10 @@ object SimklLibraryRepository { ) currentStatus != null -> { - require(snapshot.canSafelyRemoveFromSimklLibrary(item.id)) { + require( + snapshot.membershipRemovalConfirmation(item.id) == null || + destructiveRemovalConfirmed, + ) { "Removing this item from Simkl would also clear watched history or a rating" } SimklMutationRepository.removeFromList(profileId = profileId, items = listOf(media)) @@ -125,6 +131,20 @@ object SimklLibraryRepository { return resolution } + fun membershipRemovalConfirmation( + item: LibraryItem, + desiredMembership: Map, + ): TrackingMembershipRemovalConfirmation? { + ensureLoaded() + val removesStatus = simklLibraryStatusDefinitions.none { definition -> + desiredMembership[definition.key] == true + } && findItem(item.id, item.type)?.listKeys.orEmpty().any { key -> + simklLibraryStatusDefinition(key) != null + } + if (!removesStatus) return null + return SimklSyncRepository.state.value.snapshot.membershipRemovalConfirmation(item.id) + } + private fun publish(syncState: SimklSyncUiState) { val projection = syncState.snapshot.toSimklLibraryProjection() _uiState.value = SimklLibraryUiState( @@ -191,39 +211,52 @@ object SimklTrackingLibraryProvider : TrackingLibraryProvider { override suspend fun membership(item: LibraryItem): Map = SimklLibraryRepository.statusMembership(item.id, item.type) + override fun toggledDefaultMembership( + currentMembership: Map, + ): Map = currentMembership.mapValues { false }.toMutableMap().apply { + if (currentMembership.values.none { isSelected -> isSelected }) { + this[simklLibraryStatusDefinitions.single { definition -> + definition.status == SimklListStatus.PLAN_TO_WATCH + }.key] = true + } + } + + override fun membershipRemovalConfirmation( + item: LibraryItem, + desiredMembership: Map, + ): TrackingMembershipRemovalConfirmation? = + SimklLibraryRepository.membershipRemovalConfirmation(item, desiredMembership) + override suspend fun applyMembership( profileId: Int, item: LibraryItem, desiredMembership: Map, + destructiveRemovalConfirmed: Boolean, ): TrackingMembershipResolution? = SimklLibraryRepository.applyStatusMembership( profileId = profileId, item = item, desiredMembership = desiredMembership, + destructiveRemovalConfirmed = destructiveRemovalConfirmed, ) - - override suspend fun toggleDefaultMembership(profileId: Int, item: LibraryItem) { - val current = SimklLibraryRepository.statusMembership(item.id, item.type) - val desired = current.mapValues { false }.toMutableMap() - if (current.values.none { isSelected -> isSelected }) { - desired[simklLibraryStatusDefinitions.single { definition -> - definition.status == SimklListStatus.PLAN_TO_WATCH - }.key] = true - } - SimklLibraryRepository.applyStatusMembership( - profileId = profileId, - item = item, - desiredMembership = desired, - ) - } } -private fun SimklSyncSnapshot.canSafelyRemoveFromSimklLibrary(contentId: String): Boolean = +internal fun SimklSyncSnapshot.membershipRemovalConfirmation( + contentId: String, +): TrackingMembershipRemovalConfirmation? = entries.firstOrNull { entry -> entry.media?.canonicalContentId().equals(contentId, ignoreCase = true) } - ?.let { entry -> + ?.takeUnless { entry -> entry.status == SimklListStatus.PLAN_TO_WATCH && entry.lastWatchedAt == null && entry.userRating == null && entry.seasons.none { season -> season.episodes.any { episode -> episode.watchedAt != null } } } - ?: true + ?.let { + TrackingMembershipRemovalConfirmation( + providerId = TrackingProviderId.SIMKL, + impacts = setOf( + TrackingMembershipRemovalImpact.WATCHED_HISTORY, + TrackingMembershipRemovalImpact.RATING, + ), + ) + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingLibraryMembership.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingLibraryMembership.kt index 9d72aec4..b0118574 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingLibraryMembership.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingLibraryMembership.kt @@ -9,9 +9,23 @@ data class TrackingMembershipResolution( get() = requestedListKey != resolvedListKey } +enum class TrackingMembershipRemovalImpact { + WATCHED_HISTORY, + RATING, +} + +data class TrackingMembershipRemovalConfirmation( + val providerId: TrackingProviderId, + val impacts: Set, +) + data class TrackingMembershipApplyResult( val resolutions: List = emptyList(), + val requiredRemovalConfirmations: List = emptyList(), ) { val rewrites: List get() = resolutions.filter(TrackingMembershipResolution::wasRewritten) + + val requiresRemovalConfirmation: Boolean + get() = requiredRemovalConfirmations.isNotEmpty() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt index b0d89b66..5cbdab6a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt @@ -89,12 +89,17 @@ interface TrackingLibraryProvider { fun contains(contentId: String, contentType: String? = null): Boolean fun find(contentId: String): LibraryItem? suspend fun membership(item: LibraryItem): Map + fun toggledDefaultMembership(currentMembership: Map): Map + fun membershipRemovalConfirmation( + item: LibraryItem, + desiredMembership: Map, + ): TrackingMembershipRemovalConfirmation? = null suspend fun applyMembership( profileId: Int, item: LibraryItem, desiredMembership: Map, + destructiveRemovalConfirmed: Boolean = false, ): TrackingMembershipResolution? - suspend fun toggleDefaultMembership(profileId: Int, item: LibraryItem) } /** Provider adapter for watched-history projection and explicit history mutations. */ diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt index 39f82e9c..1bdd28d4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt @@ -197,16 +197,6 @@ object TraktLibraryRepository { return TraktMembershipSnapshot(listMembership = map) } - suspend fun toggleWatchlist(item: LibraryItem) { - ensureLoaded() - val snapshot = getMembershipSnapshot(item) - val currentlyInWatchlist = snapshot.listMembership[WATCHLIST_KEY] == true - val desired = snapshot.listMembership.toMutableMap().apply { - this[WATCHLIST_KEY] = !currentlyInWatchlist - } - applyMembershipChanges(item, TraktMembershipChanges(desiredMembership = desired)) - } - suspend fun applyMembershipChanges(item: LibraryItem, changes: TraktMembershipChanges) { ensureLoaded() val headers = TraktAuthRepository.authorizedHeaders() ?: return diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt index d8b2b1aa..37767810 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt @@ -66,10 +66,21 @@ object TraktTrackingLibraryProvider : TrackingLibraryProvider { override suspend fun membership(item: LibraryItem): Map = TraktLibraryRepository.getMembershipSnapshot(item).listMembership + override fun toggledDefaultMembership( + currentMembership: Map, + ): Map { + val watchlistKey = snapshot().tabs + .firstOrNull { tab -> tab.kind == TrackingLibraryTabKind.WATCHLIST } + ?.key + ?: return currentMembership + return toggledTraktWatchlistMembership(currentMembership, watchlistKey) + } + override suspend fun applyMembership( profileId: Int, item: LibraryItem, desiredMembership: Map, + destructiveRemovalConfirmed: Boolean, ): TrackingMembershipResolution? { TraktLibraryRepository.applyMembershipChanges( item = item, @@ -77,9 +88,13 @@ object TraktTrackingLibraryProvider : TrackingLibraryProvider { ) return null } +} - override suspend fun toggleDefaultMembership(profileId: Int, item: LibraryItem) = - TraktLibraryRepository.toggleWatchlist(item) +internal fun toggledTraktWatchlistMembership( + currentMembership: Map, + watchlistKey: String, +): Map = currentMembership.toMutableMap().apply { + this[watchlistKey] = currentMembership[watchlistKey] != true } private fun TraktListTab.toTrackingLibraryTab(): TrackingLibraryTab = diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt index 361a6ecc..1fb08daa 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt @@ -1,6 +1,7 @@ package com.nuvio.app.features.simkl import com.nuvio.app.features.tracking.TrackingMediaKind +import com.nuvio.app.features.tracking.TrackingMembershipRemovalImpact import com.nuvio.app.features.watchprogress.WatchProgressSourceSimklPlayback import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -233,6 +234,95 @@ class SimklProjectionsTest { assertEquals(4, reference.episode?.number) } + @Test + fun `clean plan to watch removal needs no destructive confirmation`() { + val plan = entry( + type = SimklMediaType.MOVIES, + status = SimklListStatus.PLAN_TO_WATCH, + id = 53536, + imdb = "tt0181852", + ) + + val confirmation = SimklSyncSnapshot(entries = listOf(plan)) + .membershipRemovalConfirmation("tt0181852") + + assertNull(confirmation) + } + + @Test + fun `watched or rated Simkl removal requires destructive confirmation`() { + val watchedPlan = entry( + type = SimklMediaType.MOVIES, + status = SimklListStatus.PLAN_TO_WATCH, + id = 53536, + imdb = "tt0181852", + lastWatchedAt = "2023-11-14T22:13:20Z", + ) + val ratedPlan = entry( + type = SimklMediaType.MOVIES, + status = SimklListStatus.PLAN_TO_WATCH, + id = 53434, + imdb = "tt0068646", + ).copy(userRating = 9) + val episodePlan = entry( + type = SimklMediaType.SHOWS, + status = SimklListStatus.PLAN_TO_WATCH, + id = 2090, + imdb = "tt1520211", + seasons = listOf( + SimklSeason( + number = 1, + episodes = listOf( + SimklEpisode(number = 1, watchedAt = "2023-11-14T22:13:20Z"), + ), + ), + ), + ) + + val confirmation = assertNotNull( + SimklSyncSnapshot(entries = listOf(watchedPlan, ratedPlan, episodePlan)) + .membershipRemovalConfirmation("tt0181852"), + ) + assertNotNull( + SimklSyncSnapshot(entries = listOf(watchedPlan, ratedPlan, episodePlan)) + .membershipRemovalConfirmation("tt0068646"), + ) + assertNotNull( + SimklSyncSnapshot(entries = listOf(watchedPlan, ratedPlan, episodePlan)) + .membershipRemovalConfirmation("tt1520211"), + ) + + assertEquals( + setOf( + TrackingMembershipRemovalImpact.WATCHED_HISTORY, + TrackingMembershipRemovalImpact.RATING, + ), + confirmation.impacts, + ) + } + + @Test + fun `Simkl default membership toggles only its mutually exclusive status`() { + val planKey = simklLibraryStatusDefinitions.single { definition -> + definition.status == SimklListStatus.PLAN_TO_WATCH + }.key + val watchingKey = simklLibraryStatusDefinitions.single { definition -> + definition.status == SimklListStatus.WATCHING + }.key + val emptyMembership = simklLibraryStatusDefinitions.associate { definition -> + definition.key to false + } + + val added = SimklTrackingLibraryProvider.toggledDefaultMembership(emptyMembership) + val removed = SimklTrackingLibraryProvider.toggledDefaultMembership( + emptyMembership + (watchingKey to true), + ) + + assertTrue(added[planKey] == true) + assertTrue(added.filterKeys { key -> key != planKey }.values.none { it }) + assertTrue(removed.values.none { it }) + } + @Test fun `timestamp parser accepts UTC fractions and rejects invalid calendar values`() { assertEquals(0L, parseSimklUtcEpochMs("1970-01-01T00:00:00Z")) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProviderTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProviderTest.kt new file mode 100644 index 00000000..c5bf96a3 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProviderTest.kt @@ -0,0 +1,22 @@ +package com.nuvio.app.features.trakt + +import kotlin.test.Test +import kotlin.test.assertEquals + +class TraktTrackingLibraryProviderTest { + @Test + fun `default toggle changes only watchlist membership`() { + val membership = mapOf( + "trakt:watchlist" to false, + "trakt:list:42" to true, + ) + + val added = toggledTraktWatchlistMembership(membership, "trakt:watchlist") + val removed = toggledTraktWatchlistMembership(added, "trakt:watchlist") + + assertEquals(true, added["trakt:watchlist"]) + assertEquals(true, added["trakt:list:42"]) + assertEquals(false, removed["trakt:watchlist"]) + assertEquals(true, removed["trakt:list:42"]) + } +} From 1906518322512c90b054d6ac8871ef9000a03af7 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:16:32 +0530 Subject: [PATCH 39/59] fix(library): simplify Simkl watchlist presentation --- .../commonMain/composeResources/values/strings.xml | 1 - .../com/nuvio/app/features/library/LibraryScreen.kt | 12 ------------ .../app/features/simkl/SimklLibraryProjection.kt | 10 +++++----- .../nuvio/app/features/simkl/SimklProjectionsTest.kt | 8 ++++++++ 4 files changed, 13 insertions(+), 18 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 031b2e37..4ddb9b1b 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1679,7 +1679,6 @@ Select type Show horizontal shelves Show vertical grid - Refresh library Couldn't load library Other Oldest added diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt index 51bcf130..618a9a6a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt @@ -260,18 +260,6 @@ fun LibraryScreen( modifier = Modifier.padding(horizontal = 16.dp), actions = { if (sourceMode == LibraryViewMode.Saved) { - if (isRemoteSource) { - IconButton( - onClick = retryLibraryLoad, - enabled = !uiState.isLoading, - ) { - Icon( - imageVector = Icons.Rounded.Refresh, - contentDescription = stringResource(Res.string.library_refresh), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } val targetLayout = if (displaySettings.layoutMode == LibraryLayoutMode.HORIZONTAL) { LibraryLayoutMode.VERTICAL } else { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt index 237ba5e2..ac50f1c2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt @@ -26,28 +26,28 @@ internal val simklLibraryStatusDefinitions = listOf( SimklLibraryStatusDefinition( status = SimklListStatus.WATCHING, key = "simkl:status:watching", - title = "Simkl Watching", + title = "Watching", trackingStatus = TrackingListStatus.WATCHING, supportedContentTypes = setOf("series"), ), SimklLibraryStatusDefinition( status = SimklListStatus.PLAN_TO_WATCH, key = "simkl:status:plantowatch", - title = "Simkl Plan to Watch", + title = "Plan to Watch", trackingStatus = TrackingListStatus.PLAN_TO_WATCH, supportedContentTypes = setOf("movie", "series"), ), SimklLibraryStatusDefinition( status = SimklListStatus.ON_HOLD, key = "simkl:status:hold", - title = "Simkl On Hold", + title = "On Hold", trackingStatus = TrackingListStatus.ON_HOLD, supportedContentTypes = setOf("series"), ), SimklLibraryStatusDefinition( status = SimklListStatus.COMPLETED, key = "simkl:status:completed", - title = "Simkl Completed", + title = "Completed", trackingStatus = TrackingListStatus.COMPLETED, supportedContentTypes = setOf("movie", "series"), isMembershipDestination = false, @@ -55,7 +55,7 @@ internal val simklLibraryStatusDefinitions = listOf( SimklLibraryStatusDefinition( status = SimklListStatus.DROPPED, key = "simkl:status:dropped", - title = "Simkl Dropped", + title = "Dropped", trackingStatus = TrackingListStatus.DROPPED, supportedContentTypes = setOf("movie", "series"), ), diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt index 1fb08daa..08f3b80c 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt @@ -13,6 +13,14 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class SimklProjectionsTest { + @Test + fun `library presentation uses status names without a provider prefix`() { + assertEquals( + listOf("Watching", "Plan to Watch", "On Hold", "Completed", "Dropped"), + simklLibraryStatusDefinitions.map { definition -> definition.title }, + ) + } + @Test fun `library projection exposes every populated status with attribution`() { val plan = entry( From 96d0a31f45d18e937c4c31a7240ec01969226199 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:59:51 +0530 Subject: [PATCH 40/59] fix(simkl): deduplicate startup activity checks --- .../app/features/library/LibraryRepository.kt | 4 +- .../simkl/SimklApplicationAdapters.kt | 5 +- .../app/features/simkl/SimklLibraryAdapter.kt | 1 + .../app/features/simkl/SimklRefreshPolicy.kt | 2 + .../app/features/tracking/TrackingReads.kt | 1 + .../trakt/TraktTrackingLibraryProvider.kt | 1 + .../features/simkl/SimklRefreshPolicyTest.kt | 60 +++++++++++++++++-- .../trakt/TraktTrackingLibraryProviderTest.kt | 9 +++ 8 files changed, 73 insertions(+), 10 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt index 44c3d896..eb11aa9f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt @@ -102,8 +102,8 @@ object LibraryRepository { activeLibraryProvider()?.let { provider -> refreshLibraryProvider( provider = provider, - reason = "authentication change", - intent = TrackingRefreshIntent.INVALIDATED, + reason = "connection state change", + intent = provider.connectionRefreshIntent, ) } publish() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt index 381ba2d5..4c6c1a48 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt @@ -170,9 +170,6 @@ object SimklProgressRepository { } } -internal fun simklProgressRefreshIntent(sourceChanged: Boolean): TrackingRefreshIntent = - if (sourceChanged) TrackingRefreshIntent.INVALIDATED else TrackingRefreshIntent.AUTOMATIC - object SimklTrackingProgressProvider : TrackingProgressProvider { override val providerId: TrackingProviderId = TrackingProviderId.SIMKL override val changes: Flow = SimklProgressRepository.uiState.map { Unit } @@ -182,7 +179,7 @@ object SimklTrackingProgressProvider : TrackingProgressProvider { override fun onProfileChanged() = SimklProgressRepository.ensureLoaded() override suspend fun refresh(force: Boolean, sourceChanged: Boolean) = - SimklProgressRepository.refresh(simklProgressRefreshIntent(sourceChanged)) + SimklProgressRepository.refresh(simklProgressRefreshIntent) override fun snapshot(): TrackingProgressSnapshot { val state = SimklProgressRepository.uiState.value diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt index d10b06ee..f4b5830f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt @@ -166,6 +166,7 @@ object SimklLibraryRepository { object SimklTrackingLibraryProvider : TrackingLibraryProvider { override val providerId: TrackingProviderId = TrackingProviderId.SIMKL override val changes: Flow = SimklLibraryRepository.uiState.map { Unit } + override val connectionRefreshIntent: TrackingRefreshIntent = simklConnectionRefreshIntent override fun ensureLoaded() = SimklLibraryRepository.ensureLoaded() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicy.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicy.kt index b40b5fd5..333668b1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicy.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicy.kt @@ -8,6 +8,8 @@ import kotlinx.coroutines.sync.withLock internal const val SIMKL_AUTOMATIC_REFRESH_INTERVAL_MINUTES = 15 internal const val SIMKL_AUTOMATIC_REFRESH_INTERVAL_MS = SIMKL_AUTOMATIC_REFRESH_INTERVAL_MINUTES * 60L * 1_000L +internal val simklConnectionRefreshIntent = TrackingRefreshIntent.AUTOMATIC +internal val simklProgressRefreshIntent = TrackingRefreshIntent.AUTOMATIC internal fun shouldRunSimklRefresh( intent: TrackingRefreshIntent, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt index 5cbdab6a..f0c500c8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt @@ -79,6 +79,7 @@ enum class TrackingRefreshIntent { interface TrackingLibraryProvider { val providerId: TrackingProviderId val changes: Flow + val connectionRefreshIntent: TrackingRefreshIntent fun ensureLoaded() fun prepare() = Unit diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt index 37767810..b0670a7b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.map object TraktTrackingLibraryProvider : TrackingLibraryProvider { override val providerId: TrackingProviderId = TrackingProviderId.TRAKT override val changes: Flow = TraktLibraryRepository.uiState.map { Unit } + override val connectionRefreshIntent: TrackingRefreshIntent = TrackingRefreshIntent.INVALIDATED override fun ensureLoaded() = TraktLibraryRepository.ensureLoaded() diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt index 849c3f8e..3eace5e7 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt @@ -11,14 +11,14 @@ import kotlin.test.assertTrue class SimklRefreshPolicyTest { @Test - fun `progress refresh keeps provider policy unless the source changes`() { + fun `startup refresh paths share automatic freshness`() { assertEquals( TrackingRefreshIntent.AUTOMATIC, - simklProgressRefreshIntent(sourceChanged = false), + simklConnectionRefreshIntent, ) assertEquals( - TrackingRefreshIntent.INVALIDATED, - simklProgressRefreshIntent(sourceChanged = true), + TrackingRefreshIntent.AUTOMATIC, + simklProgressRefreshIntent, ) } @@ -119,6 +119,58 @@ class SimklRefreshPolicyTest { assertEquals(1, executions) } + @Test + fun `sequential startup refresh paths execute only once`() = runBlocking { + val gate = SimklRefreshGate() + var lastCheckedAtEpochMs: Long? = null + var executions = 0 + + listOf(simklConnectionRefreshIntent, simklProgressRefreshIntent).forEach { intent -> + gate.runIfNeeded( + profileGeneration = 7L, + shouldRun = { + shouldRunSimklRefresh( + intent = intent, + lastCheckedAtEpochMs = lastCheckedAtEpochMs, + nowEpochMs = 1_000L, + hasError = false, + ) + }, + ) { + executions += 1 + lastCheckedAtEpochMs = 1_000L + } + } + + assertEquals(1, executions) + } + + @Test + fun `mutation invalidation still refreshes after startup check`() = runBlocking { + val gate = SimklRefreshGate() + var lastCheckedAtEpochMs: Long? = null + var executions = 0 + + listOf(simklConnectionRefreshIntent, TrackingRefreshIntent.INVALIDATED).forEach { intent -> + gate.runIfNeeded( + profileGeneration = 7L, + shouldRun = { + shouldRunSimklRefresh( + intent = intent, + lastCheckedAtEpochMs = lastCheckedAtEpochMs, + nowEpochMs = 1_000L, + hasError = false, + ) + }, + ) { + executions += 1 + lastCheckedAtEpochMs = 1_000L + } + } + + assertEquals(2, executions) + } + @Test fun `a new profile generation is not coalesced with an old profile refresh`() = runBlocking { val gate = SimklRefreshGate() diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProviderTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProviderTest.kt index c5bf96a3..ed2f0b9b 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProviderTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProviderTest.kt @@ -1,9 +1,18 @@ package com.nuvio.app.features.trakt +import com.nuvio.app.features.tracking.TrackingRefreshIntent import kotlin.test.Test import kotlin.test.assertEquals class TraktTrackingLibraryProviderTest { + @Test + fun `connection events preserve forced Trakt refreshes`() { + assertEquals( + TrackingRefreshIntent.INVALIDATED, + TraktTrackingLibraryProvider.connectionRefreshIntent, + ) + } + @Test fun `default toggle changes only watchlist membership`() { val membership = mapOf( From 237d2308f24886dac9deab172573f3b6fc20b9e1 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:35:29 +0530 Subject: [PATCH 41/59] chore(simkl): trace refresh request origins --- .../app/features/library/LibraryRepository.kt | 4 +++ .../simkl/SimklApplicationAdapters.kt | 20 ++++++++--- .../app/features/simkl/SimklAuthRepository.kt | 5 ++- .../app/features/simkl/SimklLibraryAdapter.kt | 5 ++- .../features/simkl/SimklMutationRepository.kt | 5 ++- .../app/features/simkl/SimklRefreshPolicy.kt | 15 +++++--- .../app/features/simkl/SimklSyncRepository.kt | 29 +++++++++++++-- .../features/simkl/SimklWatchDiagnostics.kt | 35 +++++++++++++++---- .../watchprogress/WatchProgressRepository.kt | 5 +++ .../features/simkl/SimklRefreshPolicyTest.kt | 29 +++++++++++---- 10 files changed, 127 insertions(+), 25 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt index eb11aa9f..a811de25 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt @@ -655,6 +655,10 @@ object LibraryRepository { reason: String, intent: TrackingRefreshIntent, ) { + log.i { + "Tracking library refresh request provider=${provider.providerId.storageId} " + + "reason=$reason intent=$intent" + } try { provider.refresh(intent) } catch (error: CancellationException) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt index 4c6c1a48..1ae732ba 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt @@ -26,7 +26,10 @@ object SimklWatchedSyncAdapter : TrackingWatchedProvider { override val providerId: TrackingProviderId = TrackingProviderId.SIMKL override suspend fun pull(profileId: Int, pageSize: Int): List { if (profileId != ProfileRepository.activeProfileId) return emptyList() - SimklSyncRepository.refresh(TrackingRefreshIntent.AUTOMATIC) + SimklSyncRepository.refresh( + intent = TrackingRefreshIntent.AUTOMATIC, + origin = SimklRefreshOrigin.WATCHED_ITEMS, + ) val snapshot = SimklSyncRepository.state.value.snapshot val projection = snapshot.toSimklWatchedProjection() SimklWatchDiagnostics.logProjection( @@ -39,7 +42,10 @@ object SimklWatchedSyncAdapter : TrackingWatchedProvider { override suspend fun pullFullyWatchedSeriesKeys(profileId: Int): Set? { if (profileId != ProfileRepository.activeProfileId) return null - SimklSyncRepository.refresh(TrackingRefreshIntent.AUTOMATIC) + SimklSyncRepository.refresh( + intent = TrackingRefreshIntent.AUTOMATIC, + origin = SimklRefreshOrigin.WATCHED_SERIES, + ) val snapshot = SimklSyncRepository.state.value.snapshot val projection = snapshot.toSimklWatchedProjection() SimklWatchDiagnostics.logProjection( @@ -120,7 +126,10 @@ object SimklProgressRepository { } suspend fun refresh(intent: TrackingRefreshIntent) { - SimklSyncRepository.refresh(intent) + SimklSyncRepository.refresh( + intent = intent, + origin = SimklRefreshOrigin.PROGRESS, + ) publish(SimklSyncRepository.state.value) } @@ -156,7 +165,10 @@ object SimklProgressRepository { } SimklSyncRepository.commitPlaybackRemoval(removed) if (removed.isNotEmpty()) { - SimklSyncRepository.refreshAsync(TrackingRefreshIntent.INVALIDATED) + SimklSyncRepository.refreshAsync( + intent = TrackingRefreshIntent.INVALIDATED, + origin = SimklRefreshOrigin.PLAYBACK_REMOVAL, + ) } } 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 6feed1dc..d4081682 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 @@ -272,7 +272,10 @@ object SimklAuthRepository : TrackingAuthProvider { persistMetadata() publish(isLoading = false, error = null) fetchAndStoreUserSettings() - SimklSyncRepository.refreshAsync(TrackingRefreshIntent.INVALIDATED) + SimklSyncRepository.refreshAsync( + intent = TrackingRefreshIntent.INVALIDATED, + origin = SimklRefreshOrigin.AUTHORIZATION, + ) } private suspend fun fetchAndStoreUserSettings(activityWatermark: String? = null): Boolean { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt index f4b5830f..391936b1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt @@ -49,7 +49,10 @@ object SimklLibraryRepository { } suspend fun refresh(intent: TrackingRefreshIntent) { - SimklSyncRepository.refresh(intent) + SimklSyncRepository.refresh( + intent = intent, + origin = SimklRefreshOrigin.LIBRARY, + ) publish(SimklSyncRepository.state.value) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt index 950b304d..79041d08 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt @@ -126,7 +126,10 @@ object SimklMutationRepository : TrackingListWriter, TrackingHistoryWriter, Trac SimklMutationService( client = SimklApi.client, onMutationCommitted = { - SimklSyncRepository.refreshAsync(TrackingRefreshIntent.INVALIDATED) + SimklSyncRepository.refreshAsync( + intent = TrackingRefreshIntent.INVALIDATED, + origin = SimklRefreshOrigin.MUTATION, + ) }, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicy.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicy.kt index 333668b1..f23abfd8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicy.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicy.kt @@ -25,6 +25,12 @@ internal fun shouldRunSimklRefresh( return elapsedMs < 0L || elapsedMs >= automaticIntervalMs } +internal enum class SimklRefreshGateOutcome { + COALESCED, + EXECUTED, + FRESHNESS_SKIPPED, +} + /** * Serializes provider refreshes and coalesces callers that overlap for the same profile generation. * @@ -40,19 +46,20 @@ internal class SimklRefreshGate { profileGeneration: Long, shouldRun: () -> Boolean, block: suspend () -> Unit, - ) { + ): SimklRefreshGateOutcome { val observedSequence = completionSequence.value - mutex.withLock { + return mutex.withLock { if ( completionSequence.value != observedSequence && lastCompletedProfileGeneration == profileGeneration ) { - return + return@withLock SimklRefreshGateOutcome.COALESCED } - if (!shouldRun()) return + if (!shouldRun()) return@withLock SimklRefreshGateOutcome.FRESHNESS_SKIPPED try { block() + SimklRefreshGateOutcome.EXECUTED } finally { lastCompletedProfileGeneration = profileGeneration completionSequence.incrementAndGet() 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 index 19494aa1..0045f606 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt @@ -6,6 +6,7 @@ import com.nuvio.app.features.tracking.TrackingProfileStore import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.tracking.TrackingProviderRegistry import com.nuvio.app.features.tracking.TrackingRefreshIntent +import kotlinx.atomicfu.atomic import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -29,6 +30,7 @@ object SimklSyncRepository : TrackingProfileStore { } private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val refreshGate = SimklRefreshGate() + private val refreshRequestSequence = atomic(0L) private val engine = SimklSyncEngine( remote = SimklApiSyncRemote(), nowEpochMs = SimklPlatformClock::nowEpochMs, @@ -61,21 +63,39 @@ object SimklSyncRepository : TrackingProfileStore { } fun refreshAsync(intent: TrackingRefreshIntent) { - scope.launch { refresh(intent) } + refreshAsync(intent, SimklRefreshOrigin.UNKNOWN) + } + + internal fun refreshAsync( + intent: TrackingRefreshIntent, + origin: SimklRefreshOrigin, + ) { + scope.launch { refresh(intent, origin) } } suspend fun refresh(intent: TrackingRefreshIntent) { + refresh(intent, SimklRefreshOrigin.MANUAL_SYNC) + } + + internal suspend fun refresh( + intent: TrackingRefreshIntent, + origin: SimklRefreshOrigin, + ) { ensureLoaded() + val requestId = refreshRequestSequence.incrementAndGet() val requestedGeneration = profileGeneration val before = _state.value SimklWatchDiagnostics.logRefreshRequest( + requestId = requestId, + origin = origin, intent = intent, + profileId = ProfileRepository.activeProfileId, profileGeneration = requestedGeneration, authenticated = SimklAuthRepository.isAuthenticated.value, snapshot = before.snapshot, errorMessage = before.errorMessage, ) - refreshGate.runIfNeeded( + val outcome = refreshGate.runIfNeeded( profileGeneration = requestedGeneration, shouldRun = { val current = _state.value @@ -90,6 +110,8 @@ object SimklSyncRepository : TrackingProfileStore { hasError = current.errorMessage != null, ) SimklWatchDiagnostics.logRefreshDecision( + requestId = requestId, + origin = origin, intent = intent, nowEpochMs = nowEpochMs, lastCheckedAtEpochMs = current.snapshot.lastCheckedAtEpochMs, @@ -103,7 +125,10 @@ object SimklSyncRepository : TrackingProfileStore { refreshSnapshot(requestedGeneration) } SimklWatchDiagnostics.logRefreshCompletion( + requestId = requestId, + origin = origin, intent = intent, + outcome = outcome, before = before, after = _state.value, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklWatchDiagnostics.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklWatchDiagnostics.kt index 7fd359a7..bbc8d4e9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklWatchDiagnostics.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklWatchDiagnostics.kt @@ -3,6 +3,18 @@ package com.nuvio.app.features.simkl import co.touchlab.kermit.Logger import com.nuvio.app.features.tracking.TrackingRefreshIntent +internal enum class SimklRefreshOrigin { + AUTHORIZATION, + LIBRARY, + MANUAL_SYNC, + MUTATION, + PLAYBACK_REMOVAL, + PROGRESS, + UNKNOWN, + WATCHED_ITEMS, + WATCHED_SERIES, +} + internal object SimklWatchDiagnostics { private val log = Logger.withTag("SimklDiag") @@ -37,20 +49,26 @@ internal object SimklWatchDiagnostics { } fun logRefreshRequest( + requestId: Long, + origin: SimklRefreshOrigin, intent: TrackingRefreshIntent, + profileId: Int, profileGeneration: Long, authenticated: Boolean, snapshot: SimklSyncSnapshot, errorMessage: String?, ) { - log.d { - "refresh request intent=$intent generation=$profileGeneration authenticated=$authenticated " + + log.i { + "refresh request id=$requestId origin=$origin intent=$intent profile=$profileId " + + "generation=$profileGeneration authenticated=$authenticated " + "initialized=${snapshot.isInitialized} hasLastChecked=${snapshot.lastCheckedAtEpochMs != null} " + "hasWatermark=${snapshot.watermark != null} hasError=${errorMessage != null}" } } fun logRefreshDecision( + requestId: Long, + origin: SimklRefreshOrigin, intent: TrackingRefreshIntent, nowEpochMs: Long, lastCheckedAtEpochMs: Long?, @@ -58,20 +76,25 @@ internal object SimklWatchDiagnostics { hasError: Boolean, eligible: Boolean, ) { - log.d { - "refresh decision intent=$intent eligible=$eligible authenticated=$authenticated " + + log.i { + "refresh decision id=$requestId origin=$origin intent=$intent eligible=$eligible " + + "authenticated=$authenticated " + "hasError=$hasError " + "elapsedMs=${lastCheckedAtEpochMs?.let { nowEpochMs - it }}" } } fun logRefreshCompletion( + requestId: Long, + origin: SimklRefreshOrigin, intent: TrackingRefreshIntent, + outcome: SimklRefreshGateOutcome, before: SimklSyncUiState, after: SimklSyncUiState, ) { - log.d { - "refresh completion intent=$intent checkedChanged=" + + log.i { + "refresh completion id=$requestId origin=$origin intent=$intent outcome=$outcome " + + "checkedChanged=" + "${before.snapshot.lastCheckedAtEpochMs != after.snapshot.lastCheckedAtEpochMs} " + "watermarkChanged=${before.snapshot.watermark != after.snapshot.watermark} " + "entriesBefore=${before.snapshot.entries.size} entriesAfter=${after.snapshot.entries.size} " + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index 8dbe2339..c19fabcf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -422,6 +422,11 @@ object WatchProgressRepository { log.d { "Skipping ${provider.providerId.storageId} progress refresh because it is unavailable" } return false } + log.i { + "Tracking progress refresh request profile=$profileId provider=${provider.providerId.storageId} " + + "source=$activeSource sourceChanged=$sourceChanged force=$force " + + "generation=$operationGeneration" + } return try { provider.refresh(force = force, sourceChanged = sourceChanged) if ( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt index 3eace5e7..11e59588 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt @@ -114,8 +114,8 @@ class SimklRefreshPolicyTest { secondStarted.await() releaseFirst.complete(Unit) - first.await() - second.await() + assertEquals(SimklRefreshGateOutcome.EXECUTED, first.await()) + assertEquals(SimklRefreshGateOutcome.COALESCED, second.await()) assertEquals(1, executions) } @@ -125,7 +125,7 @@ class SimklRefreshPolicyTest { var lastCheckedAtEpochMs: Long? = null var executions = 0 - listOf(simklConnectionRefreshIntent, simklProgressRefreshIntent).forEach { intent -> + val outcomes = listOf(simklConnectionRefreshIntent, simklProgressRefreshIntent).map { intent -> gate.runIfNeeded( profileGeneration = 7L, shouldRun = { @@ -142,6 +142,13 @@ class SimklRefreshPolicyTest { } } + assertEquals( + listOf( + SimklRefreshGateOutcome.EXECUTED, + SimklRefreshGateOutcome.FRESHNESS_SKIPPED, + ), + outcomes, + ) assertEquals(1, executions) } @@ -151,7 +158,10 @@ class SimklRefreshPolicyTest { var lastCheckedAtEpochMs: Long? = null var executions = 0 - listOf(simklConnectionRefreshIntent, TrackingRefreshIntent.INVALIDATED).forEach { intent -> + val outcomes = listOf( + simklConnectionRefreshIntent, + TrackingRefreshIntent.INVALIDATED, + ).map { intent -> gate.runIfNeeded( profileGeneration = 7L, shouldRun = { @@ -168,6 +178,13 @@ class SimklRefreshPolicyTest { } } + assertEquals( + listOf( + SimklRefreshGateOutcome.EXECUTED, + SimklRefreshGateOutcome.EXECUTED, + ), + outcomes, + ) assertEquals(2, executions) } @@ -196,8 +213,8 @@ class SimklRefreshPolicyTest { secondStarted.await() releaseFirst.complete(Unit) - first.await() - second.await() + assertEquals(SimklRefreshGateOutcome.EXECUTED, first.await()) + assertEquals(SimklRefreshGateOutcome.EXECUTED, second.await()) assertEquals(listOf(1L, 2L), executedGenerations) } } From 2bca3ffa6b9d7736651fe401b76a7c3ed10680ca Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:46:40 +0530 Subject: [PATCH 42/59] fix(profiles): activate selected profile once --- .../profiles/ProfileSelectionRouting.kt | 15 +++++ .../profiles/ProfileSelectionScreen.kt | 28 ++++---- .../profiles/ProfileSelectionRoutingTest.kt | 64 +++++++++++++++++++ 3 files changed, 90 insertions(+), 17 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionRouting.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/profiles/ProfileSelectionRoutingTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionRouting.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionRouting.kt new file mode 100644 index 00000000..411c69df --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionRouting.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.profiles + +internal fun routeProfileSelection( + profile: NuvioProfile, + isEditMode: Boolean, + onEditProfile: (NuvioProfile) -> Unit, + onPinRequired: (NuvioProfile) -> Unit, + onProfileSelected: (NuvioProfile) -> Unit, +) { + when { + isEditMode -> onEditProfile(profile) + profile.pinEnabled -> onPinRequired(profile) + else -> onProfileSelected(profile) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt index 012fd406..d9e32cac 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt @@ -80,6 +80,15 @@ fun ProfileSelectionScreen( val titleAlpha = remember { Animatable(0f) } val titleOffset = remember { Animatable(20f) } val manageAlpha = remember { Animatable(0f) } + val onProfileClick: (NuvioProfile) -> Unit = { profile -> + routeProfileSelection( + profile = profile, + isEditMode = isEditMode, + onEditProfile = onEditProfile, + onPinRequired = { pinDialogProfile = it }, + onProfileSelected = onProfileSelected, + ) + } LaunchedEffect(Unit) { AvatarRepository.fetchAvatars() @@ -168,14 +177,7 @@ fun ProfileSelectionScreen( isEditMode = isEditMode, animDelay = currentIndex * 80, onClick = { - if (isEditMode) { - onEditProfile(profile) - } else if (profile.pinEnabled) { - pinDialogProfile = profile - } else { - ProfileRepository.selectProfile(profile.profileIndex) - onProfileSelected(profile) - } + onProfileClick(profile) }, ) } else { @@ -209,14 +211,7 @@ fun ProfileSelectionScreen( isEditMode = isEditMode, animDelay = currentIndex * 80, onClick = { - if (isEditMode) { - onEditProfile(profile) - } else if (profile.pinEnabled) { - pinDialogProfile = profile - } else { - ProfileRepository.selectProfile(profile.profileIndex) - onProfileSelected(profile) - } + onProfileClick(profile) }, ) } else { @@ -279,7 +274,6 @@ fun ProfileSelectionScreen( onVerify = { pin -> ProfileRepository.verifyPin(profile.profileIndex, pin) }, onVerified = { pinDialogProfile = null - ProfileRepository.selectProfile(profile.profileIndex) onProfileSelected(profile) }, onDismiss = { pinDialogProfile = null }, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/profiles/ProfileSelectionRoutingTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/profiles/ProfileSelectionRoutingTest.kt new file mode 100644 index 00000000..9a87457c --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/profiles/ProfileSelectionRoutingTest.kt @@ -0,0 +1,64 @@ +package com.nuvio.app.features.profiles + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ProfileSelectionRoutingTest { + @Test + fun `unlocked profile emits one selection request`() { + val profile = NuvioProfile(profileIndex = 2) + var editRequests = 0 + var pinRequests = 0 + var selectionRequests = 0 + + routeProfileSelection( + profile = profile, + isEditMode = false, + onEditProfile = { editRequests += 1 }, + onPinRequired = { pinRequests += 1 }, + onProfileSelected = { selectionRequests += 1 }, + ) + + assertEquals(0, editRequests) + assertEquals(0, pinRequests) + assertEquals(1, selectionRequests) + } + + @Test + fun `locked profile requests verification without selecting`() { + val profile = NuvioProfile(profileIndex = 2, pinEnabled = true) + var pinRequests = 0 + var selectionRequests = 0 + + routeProfileSelection( + profile = profile, + isEditMode = false, + onEditProfile = {}, + onPinRequired = { pinRequests += 1 }, + onProfileSelected = { selectionRequests += 1 }, + ) + + assertEquals(1, pinRequests) + assertEquals(0, selectionRequests) + } + + @Test + fun `edit mode routes only to profile editing`() { + val profile = NuvioProfile(profileIndex = 2, pinEnabled = true) + var editRequests = 0 + var pinRequests = 0 + var selectionRequests = 0 + + routeProfileSelection( + profile = profile, + isEditMode = true, + onEditProfile = { editRequests += 1 }, + onPinRequired = { pinRequests += 1 }, + onProfileSelected = { selectionRequests += 1 }, + ) + + assertEquals(1, editRequests) + assertEquals(0, pinRequests) + assertEquals(0, selectionRequests) + } +} From 544c776bb83bc9fcd008a256a46493e21d699156 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:49:37 +0530 Subject: [PATCH 43/59] ref: update descriptions for Trakt and Simkl --- .../composeResources/values/strings.xml | 36 ++++++++++--------- .../settings/TrackingProviderCards.kt | 15 +++++++- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 4ddb9b1b..25902a27 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -461,7 +461,7 @@ Change to a different profile. Switch Profile Open Trakt connection screen - Connect tracking services and choose where your library and watch progress live. + Connect tracking providers and choose which one powers your Library and Continue Watching. No settings found. Search settings... RESULTS @@ -1116,20 +1116,22 @@ Disconnect Failed to open browser FEATURES - Finish Trakt sign in in your browser + Finish connection approval Sync your watchlist, watch progress, continue watching, scrobbles, and personal lists with Trakt. - Missing Trakt credentials in local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). + Missing TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET in local.properties. Open Trakt Login - Your Save actions can now target Trakt watchlist and personal lists. - Sign in with Trakt to enable list-based saving and Trakt library mode. - DATA SOURCES + Sync your watchlist, watch progress, continue watching, scrobbles, and personal lists with Trakt. + Sync your watchlist, watch progress, continue watching, scrobbles, and personal lists with Trakt. + Sources SOURCE PREFERENCES - VIEWING AND DISCOVERY - TRACKING SERVICES + Trakt features + Accounts After approval, you will be redirected back automatically. Connect %1$s first Disconnect %1$s? This removes the connection from Nuvio. Your data on %1$s stays intact, and unavailable library or progress sources temporarily fall back to Nuvio. + This will disconnect your Trakt account from Nuvio. + This removes the Simkl account and cached Simkl data from this profile. %1$s is unavailable. Using %2$s until it reconnects. The source changed, but the latest watch progress could not be refreshed. Keep saved titles in your Nuvio library. @@ -1143,15 +1145,15 @@ Connect Simkl Connected as %1$s Simkl user - Watchlist, history, playback progress, and scrobbles are synced with Simkl. + Connect Simkl to sync lists, watched history, playback progress, and scrobbles. Disconnect - Finish Simkl sign in in your browser + Finish connection approval Open Simkl Login - Sign in with Simkl to sync your plan-to-watch list, history, playback progress, and scrobbles. - Missing SIMKL_CLIENT_ID in local.properties. + Connect Simkl to sync lists, watched history, playback progress, and scrobbles. + Missing SIMKL_CLIENT_ID Visit Simkl Simkl did not accept the sign-in callback. Please try again. - The Simkl sign-in request expired. Start again. + Simkl code expired. Start again. Could not complete Simkl sign in. Please try again. Sync now How syncing works @@ -1160,16 +1162,16 @@ Each refresh first checks whether Simkl reports any changes and downloads only the updates. This follows Simkl’s API rules, reduces unnecessary data use, and helps keep the service stable. Changes made in Nuvio are sent to Simkl immediately. Use Sync now whenever you want Nuvio to check for remote changes sooner. Read the Simkl sync guide - Simkl authorization expired or was revoked. Connect again. + Simkl authorization was revoked. Connect again. Simkl Simkl watchlist selected Watch progress source set to Simkl - Choose which connected service or Nuvio Sync powers resume and Continue Watching. Scrobbles still go to every connected tracker. + Choose the service Nuvio reads for resume and Continue Watching. Scrobbling remains active for every connected service. View on Simkl Library Source - Choose which library to use for saving and viewing your collection + Choose which source Nuvio reads for your Library Library Source - Choose where to save and manage your library items + Choose the service Nuvio reads for your Library. Playback scrobbles to every connected service. Trakt Nuvio Library Trakt library selected diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingProviderCards.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingProviderCards.kt index c552b8ba..de086fbd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingProviderCards.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingProviderCards.kt @@ -80,6 +80,7 @@ import nuvio.composeapp.generated.resources.settings_simkl_connected_as import nuvio.composeapp.generated.resources.settings_simkl_connected_description import nuvio.composeapp.generated.resources.settings_simkl_default_user import nuvio.composeapp.generated.resources.settings_simkl_disconnect +import nuvio.composeapp.generated.resources.settings_simkl_disconnect_description import nuvio.composeapp.generated.resources.settings_simkl_finish_sign_in import nuvio.composeapp.generated.resources.settings_simkl_invalid_callback import nuvio.composeapp.generated.resources.settings_simkl_missing_credentials @@ -97,6 +98,7 @@ import nuvio.composeapp.generated.resources.settings_trakt_connect import nuvio.composeapp.generated.resources.settings_trakt_connected_as import nuvio.composeapp.generated.resources.settings_trakt_default_user import nuvio.composeapp.generated.resources.settings_trakt_disconnect +import nuvio.composeapp.generated.resources.settings_trakt_disconnect_description import nuvio.composeapp.generated.resources.settings_trakt_failed_open_browser import nuvio.composeapp.generated.resources.settings_trakt_finish_sign_in import nuvio.composeapp.generated.resources.settings_trakt_missing_credentials @@ -642,7 +644,18 @@ private fun TrackingDisconnectDialog( fontWeight = FontWeight.SemiBold, ) Text( - text = stringResource(Res.string.settings_tracking_disconnect_description, brand.displayName), + text = when (brand) { + TrackingBrand.TRAKT -> + stringResource(Res.string.settings_trakt_disconnect_description) + TrackingBrand.SIMKL -> + stringResource(Res.string.settings_simkl_disconnect_description) + TrackingBrand.NUVIO, + TrackingBrand.TMDB, + -> stringResource( + Res.string.settings_tracking_disconnect_description, + brand.displayName, + ) + }, style = MaterialTheme.typography.bodyMedium, color = tokens.colors.textMuted, ) From 38e3e89618abb59f8491b6d751a88ff3503bbcd3 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:22:36 +0530 Subject: [PATCH 44/59] feat(settings): add Simkl attribution --- .../features/settings/IntegrationLogoPainter.android.kt | 3 +++ .../src/commonMain/composeResources/values/strings.xml | 2 ++ .../nuvio/app/features/settings/IntegrationLogoPainter.kt | 1 + .../app/features/settings/LicensesAttributionsPage.kt | 7 +++++++ .../com/nuvio/app/features/settings/SettingsSearch.kt | 1 + .../app/features/settings/IntegrationLogoPainter.ios.kt | 3 +++ 6 files changed, 17 insertions(+) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.android.kt index a2140bde..e1edc262 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.android.kt @@ -4,6 +4,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.painterResource import com.nuvio.app.R +import com.nuvio.app.features.simkl.SimklBrandAsset +import com.nuvio.app.features.simkl.simklBrandPainter import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.introdb_favicon import nuvio.composeapp.generated.resources.rating_tmdb @@ -14,6 +16,7 @@ internal actual fun integrationLogoPainter(logo: IntegrationLogo): Painter = when (logo) { IntegrationLogo.Tmdb -> composePainterResource(Res.drawable.rating_tmdb) IntegrationLogo.Trakt -> painterResource(id = R.drawable.trakt_tv_favicon) + IntegrationLogo.Simkl -> simklBrandPainter(SimklBrandAsset.Glyph) IntegrationLogo.MdbList -> painterResource(id = R.drawable.mdblist_logo) IntegrationLogo.IntroDb -> composePainterResource(Res.drawable.introdb_favicon) } diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 25902a27..de96fb8f 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -500,6 +500,8 @@ Nuvio uses IMDb Non-Commercial Datasets, including title.ratings.tsv.gz, for IMDb ratings and vote counts. Information courtesy of IMDb (https://www.imdb.com). Used with permission. IMDb data is for personal and non-commercial use under IMDb's terms. Trakt Nuvio connects to Trakt for account authentication, watched history, progress sync, library data, ratings, lists, and comments. Nuvio is not affiliated with or endorsed by Trakt. + Simkl + Nuvio connects to Simkl for account authentication, watchlists, watched history, playback progress, and scrobbling. Movie, TV, and anime tracking data is provided by Simkl. Nuvio is not affiliated with or endorsed by Simkl. Premiumize Nuvio connects to Premiumize for account authentication, cloud library access, cache checks, and cloud playback features. Nuvio is not affiliated with or endorsed by Premiumize. TorBox diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.kt index 8bf7971d..ee35abdd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.graphics.painter.Painter internal enum class IntegrationLogo { Tmdb, Trakt, + Simkl, MdbList, IntroDb, } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/LicensesAttributionsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/LicensesAttributionsPage.kt index d86eb0a6..5c955358 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/LicensesAttributionsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/LicensesAttributionsPage.kt @@ -42,6 +42,7 @@ import org.jetbrains.compose.resources.stringResource private const val TmdbUrl = "https://www.themoviedb.org" private const val ImdbDatasetsUrl = "https://developer.imdb.com/non-commercial-datasets/" private const val TraktUrl = "https://trakt.tv" +private const val SimklUrl = "https://simkl.com" private const val PremiumizeUrl = "https://www.premiumize.me" private const val TorboxUrl = "https://torbox.app" private const val MdbListUrl = "https://mdblist.com" @@ -330,6 +331,12 @@ private fun attributionItems(): List = listOf( logo = IntegrationLogo.Trakt, link = TraktUrl, ), + AttributionItem( + titleRes = Res.string.settings_licenses_attributions_simkl_title, + bodyRes = Res.string.settings_licenses_attributions_simkl_body, + logo = IntegrationLogo.Simkl, + link = SimklUrl, + ), AttributionItem( titleRes = Res.string.settings_licenses_attributions_premiumize_title, bodyRes = Res.string.settings_licenses_attributions_premiumize_body, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt index ddcbb535..c7a3db2f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt @@ -286,6 +286,7 @@ internal fun settingsSearchEntries( PlaybackSearchRow("nuvio-license", stringResource(Res.string.settings_licenses_attributions_nuvio_title), stringResource(Res.string.settings_licenses_attributions_nuvio_license)), PlaybackSearchRow("tmdb-attribution", stringResource(Res.string.settings_licenses_attributions_tmdb_title), stringResource(Res.string.settings_licenses_attributions_tmdb_body)), PlaybackSearchRow("trakt-attribution", stringResource(Res.string.settings_licenses_attributions_trakt_title), stringResource(Res.string.settings_licenses_attributions_trakt_body)), + PlaybackSearchRow("simkl-attribution", stringResource(Res.string.settings_licenses_attributions_simkl_title), stringResource(Res.string.settings_licenses_attributions_simkl_body)), PlaybackSearchRow("premiumize-attribution", stringResource(Res.string.settings_licenses_attributions_premiumize_title), stringResource(Res.string.settings_licenses_attributions_premiumize_body)), PlaybackSearchRow("torbox-attribution", stringResource(Res.string.settings_licenses_attributions_torbox_title), stringResource(Res.string.settings_licenses_attributions_torbox_body)), PlaybackSearchRow("mdblist-attribution", stringResource(Res.string.settings_licenses_attributions_mdblist_title), stringResource(Res.string.settings_licenses_attributions_mdblist_body)), diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.ios.kt index 6aa94391..33c56d16 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.ios.kt @@ -2,6 +2,8 @@ package com.nuvio.app.features.settings import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.painter.Painter +import com.nuvio.app.features.simkl.SimklBrandAsset +import com.nuvio.app.features.simkl.simklBrandPainter import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.introdb_favicon import nuvio.composeapp.generated.resources.mdblist_logo @@ -14,6 +16,7 @@ internal actual fun integrationLogoPainter(logo: IntegrationLogo): Painter = when (logo) { IntegrationLogo.Tmdb -> painterResource(Res.drawable.rating_tmdb) IntegrationLogo.Trakt -> painterResource(Res.drawable.trakt_tv_favicon) + IntegrationLogo.Simkl -> simklBrandPainter(SimklBrandAsset.Glyph) IntegrationLogo.MdbList -> painterResource(Res.drawable.mdblist_logo) IntegrationLogo.IntroDb -> painterResource(Res.drawable.introdb_favicon) } From bb609bc3081cebf2690c08161e47200f2b8d1f9d Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:30:07 +0530 Subject: [PATCH 45/59] refactor(details): remove Simkl source action --- .../composeResources/values/strings.xml | 1 - .../app/features/details/MetaDetailsScreen.kt | 34 ------------------- 2 files changed, 35 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index de96fb8f..2ca4fd4d 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1169,7 +1169,6 @@ Simkl watchlist selected Watch progress source set to Simkl Choose the service Nuvio reads for resume and Continue Watching. Scrobbling remains active for every connected service. - View on Simkl Library Source Choose which source Nuvio reads for your Library Library Source diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 946184ff..edae5183 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -30,7 +30,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.CheckCircle @@ -126,7 +125,6 @@ import com.nuvio.app.features.tracking.TrackingMembershipApplyResult import com.nuvio.app.features.tracking.toggleTrackingLibraryMembership import com.nuvio.app.features.tracking.TrackingSettingsRepository import com.nuvio.app.features.tracking.TrackingProviderId -import com.nuvio.app.features.tracking.resolveTrackingAttribution import com.nuvio.app.features.trailer.TrailerPlaybackResolver import com.nuvio.app.features.trailer.TrailerPlaybackSource import com.nuvio.app.features.watched.WatchedRepository @@ -467,24 +465,6 @@ fun MetaDetailsScreen( ) { LibraryRepository.isSaved(meta.id, meta.type) } - val simklSourceUrl = remember( - libraryUiState.items, - watchProgressUiState.entries, - watchedUiState.items, - meta.id, - ) { - resolveTrackingAttribution( - contentId = meta.id, - providerId = TrackingProviderId.SIMKL, - items = sequence { - yieldAll(libraryUiState.items) - yieldAll(watchProgressUiState.entries) - yieldAll(watchedUiState.items) - }, - ) - ?.sourceUrl - ?.takeIf { url -> url.startsWith("https://simkl.com/") } - } val isWatched = remember(watchedUiState.watchedKeys, fullyWatchedSeriesKeys, metaPreview) { WatchingState.isPosterWatched( watchedKeys = watchedUiState.watchedKeys, @@ -1062,7 +1042,6 @@ fun MetaDetailsScreen( playButtonLabel = playButtonLabel, isSaved = isSaved, isWatched = isWatched, - simklSourceUrl = simklSourceUrl, onPrimaryPlayClick = onPrimaryPlayClick, onPrimaryPlayLongClick = onPrimaryPlayLongClick, onSaveClick = toggleSaved, @@ -1735,7 +1714,6 @@ private fun LazyListScope.configuredMetaSectionItems( playButtonLabel: String, isSaved: Boolean, isWatched: Boolean, - simklSourceUrl: String?, onPrimaryPlayClick: () -> Unit, onPrimaryPlayLongClick: (() -> Unit)?, onSaveClick: () -> Unit, @@ -1812,7 +1790,6 @@ private fun LazyListScope.configuredMetaSectionItems( playButtonLabel = playButtonLabel, isSaved = isSaved, isWatched = isWatched, - simklSourceUrl = simklSourceUrl, onPrimaryPlayClick = onPrimaryPlayClick, onPrimaryPlayLongClick = onPrimaryPlayLongClick, onSaveClick = onSaveClick, @@ -1962,7 +1939,6 @@ private fun ConfiguredMetaSections( playButtonLabel: String, isSaved: Boolean, isWatched: Boolean, - simklSourceUrl: String?, onPrimaryPlayClick: () -> Unit, onPrimaryPlayLongClick: (() -> Unit)?, onSaveClick: () -> Unit, @@ -2002,7 +1978,6 @@ private fun ConfiguredMetaSections( animatedVisibilityScope: AnimatedVisibilityScope?, ) { val enabledItems = settings.items.filter { it.enabled } - val uriHandler = LocalUriHandler.current // Helper to check if a section actually has content to show val sectionHasContent: (MetaScreenSectionKey) -> Boolean = { key -> @@ -2056,15 +2031,6 @@ private fun ConfiguredMetaSections( onClick = onSaveClick, onLongClick = onSaveLongClick, )) - simklSourceUrl?.let { sourceUrl -> - add( - DetailSecondaryAction( - label = stringResource(Res.string.details_view_on_simkl), - icon = Icons.AutoMirrored.Filled.OpenInNew, - onClick = { runCatching { uriHandler.openUri(sourceUrl) } }, - ), - ) - } }, isTablet = isTablet, onPlayClick = onPrimaryPlayClick, From 0aaea4fd2b80fc44761b3912a0d46238d6e60a63 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:38:24 +0530 Subject: [PATCH 46/59] change tracking icon --- .../kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt | 3 ++- .../kotlin/com/nuvio/app/features/settings/SettingsSearch.kt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt index f57e1bca..17f3750c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.rounded.AccountCircle import androidx.compose.material.icons.rounded.BugReport import androidx.compose.material.icons.rounded.CloudDownload @@ -109,7 +110,7 @@ internal fun LazyListScope.settingsRootContent( SettingsNavigationRow( title = stringResource(Res.string.compose_settings_page_tracking), description = stringResource(Res.string.compose_settings_root_tracking_description), - icon = Icons.Rounded.Link, + icon = Icons.Default.Sync, isTablet = isTablet, onClick = onTrackingClick, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt index c7a3db2f..68c21524 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.rounded.AccountCircle import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.CloudDownload @@ -204,7 +205,7 @@ internal fun settingsSearchEntries( title = trackingPage, description = stringResource(Res.string.compose_settings_root_tracking_description), category = accountCategory, - icon = Icons.Rounded.Link, + icon = Icons.Default.Sync, ) addPage( page = SettingsPage.Appearance, From 200851c6f6e8e7bda258c7174d9083b918676ab2 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:06:46 +0530 Subject: [PATCH 47/59] fix: scope removal animations to tracking source --- .../core/ui/ScopedDisintegrationTracker.kt | 71 ++++++++ .../com/nuvio/app/features/home/HomeScreen.kt | 8 +- .../components/HomeContinueWatchingSection.kt | 152 +++++++----------- .../app/features/library/LibraryScreen.kt | 48 +++--- .../watchprogress/WatchProgressModels.kt | 2 + .../watchprogress/WatchProgressRepository.kt | 1 + .../ui/ScopedDisintegrationTrackerTest.kt | 49 ++++++ 7 files changed, 211 insertions(+), 120 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ScopedDisintegrationTracker.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/core/ui/ScopedDisintegrationTrackerTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ScopedDisintegrationTracker.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ScopedDisintegrationTracker.kt new file mode 100644 index 00000000..bc8e256a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ScopedDisintegrationTracker.kt @@ -0,0 +1,71 @@ +package com.nuvio.app.core.ui + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +internal data class DisintegrationTrackedItem( + val key: K, + val item: T, + val exiting: Boolean, +) + +internal class ScopedDisintegrationTracker( + private val itemKey: (T) -> K, +) { + private val exiting = LinkedHashMap>() + private var previous = LinkedHashMap>() + private var activeScope: S? = null + private var hasActiveScope = false + private var invalidations by mutableStateOf(0) + + fun onDisintegrated(key: K) { + if (exiting.remove(key) != null) invalidations++ + } + + fun reset() { + exiting.clear() + previous = LinkedHashMap() + activeScope = null + hasActiveScope = false + } + + fun sync(scope: S, items: List): List> { + @Suppress("UNUSED_EXPRESSION") + invalidations + + if (!hasActiveScope || activeScope != scope) { + exiting.clear() + previous = LinkedHashMap() + activeScope = scope + hasActiveScope = true + } + + val current = LinkedHashMap>() + items.forEachIndexed { index, item -> current[itemKey(item)] = item to index } + + for ((key, info) in previous) { + if (key !in current && key !in exiting) { + exiting[key] = info + } + } + for (key in current.keys) { + exiting.remove(key) + } + previous = current + + val entries = ArrayList>(items.size + exiting.size) + items.forEach { item -> + val key = itemKey(item) + entries += DisintegrationTrackedItem(key, item, exiting = false) + } + exiting.entries + .sortedBy { it.value.second } + .forEach { (key, info) -> + val insertAt = info.second.coerceIn(0, entries.size) + entries.add(insertAt, DisintegrationTrackedItem(key, info.first, exiting = true)) + } + + return entries + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 53c91580..a22b9d31 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -140,7 +140,7 @@ fun HomeScreen( val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() val fullyWatchedSeriesKeys by WatchedRepository.fullyWatchedSeriesKeys.collectAsStateWithLifecycle() val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle() - val effectiveWatchProgressSource by WatchProgressRepository.activeSourceState.collectAsStateWithLifecycle() + val effectiveWatchProgressSource = watchProgressUiState.source val cloudLibraryUiState by CloudLibraryRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() val trackingSettingsUiState by remember { @@ -925,6 +925,7 @@ fun HomeScreen( preferences = continueWatchingPreferences, continueWatchingItems = continueWatchingItems, upcomingItems = upcomingItems, + dataSourceKey = effectiveWatchProgressSource, sectionPadding = homeSectionPadding, layout = continueWatchingLayout, continueWatchingListState = continueWatchingListState, @@ -946,6 +947,7 @@ fun HomeScreen( preferences = continueWatchingPreferences, continueWatchingItems = continueWatchingItems, upcomingItems = upcomingItems, + dataSourceKey = effectiveWatchProgressSource, sectionPadding = homeSectionPadding, layout = continueWatchingLayout, continueWatchingListState = continueWatchingListState, @@ -990,6 +992,7 @@ fun HomeScreen( preferences = continueWatchingPreferences, continueWatchingItems = continueWatchingItems, upcomingItems = upcomingItems, + dataSourceKey = effectiveWatchProgressSource, sectionPadding = homeSectionPadding, layout = continueWatchingLayout, continueWatchingListState = continueWatchingListState, @@ -1046,6 +1049,7 @@ private fun LazyListScope.homeContinueWatchingSections( preferences: ContinueWatchingPreferencesUiState, continueWatchingItems: List, upcomingItems: List, + dataSourceKey: WatchProgressSource, sectionPadding: Dp, layout: ContinueWatchingLayout, continueWatchingListState: LazyListState, @@ -1059,6 +1063,7 @@ private fun LazyListScope.homeContinueWatchingSections( item(key = HOME_CONTINUE_WATCHING_SECTION_KEY) { HomeContinueWatchingSection( items = continueWatchingItems, + dataSourceKey = dataSourceKey, style = preferences.style, useEpisodeThumbnails = preferences.useEpisodeThumbnails, blurNextUp = preferences.blurNextUp, @@ -1076,6 +1081,7 @@ private fun LazyListScope.homeContinueWatchingSections( item(key = HOME_UPCOMING_SECTION_KEY) { HomeContinueWatchingSection( items = upcomingItems, + dataSourceKey = dataSourceKey, style = preferences.style, useEpisodeThumbnails = preferences.useEpisodeThumbnails, blurNextUp = preferences.blurNextUp, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt index 37a660f3..fd7ac419 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt @@ -28,9 +28,8 @@ import androidx.compose.material3.Text import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.key import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur @@ -56,6 +55,7 @@ import com.nuvio.app.core.ui.nuvioCardDepth import com.nuvio.app.core.ui.NuvioShelfSection import com.nuvio.app.core.ui.NuvioTokens import com.nuvio.app.core.ui.PosterLandscapeAspectRatio +import com.nuvio.app.core.ui.ScopedDisintegrationTracker import com.nuvio.app.core.ui.landscapePosterHeightForWidth import com.nuvio.app.core.ui.landscapePosterWidth import com.nuvio.app.core.ui.posterCardClickable @@ -63,6 +63,7 @@ import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.features.cloud.CloudLibraryContentType import com.nuvio.app.features.cloud.cloudLibraryDisplayArtworkUrl import com.nuvio.app.features.home.HomeCatalogSettingsRepository +import com.nuvio.app.features.tracking.WatchProgressSource import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle import com.nuvio.app.features.watchprogress.CurrentDateProvider @@ -232,6 +233,7 @@ private fun firstNonBlank(vararg values: String?): String? = internal fun HomeContinueWatchingSection( items: List, style: ContinueWatchingSectionStyle, + dataSourceKey: WatchProgressSource, useEpisodeThumbnails: Boolean = true, blurNextUp: Boolean = false, modifier: Modifier = Modifier, @@ -247,6 +249,7 @@ internal fun HomeContinueWatchingSection( if (sectionPadding != null && layout != null) { HomeContinueWatchingSectionContent( items = items, + dataSourceKey = dataSourceKey, style = style, useEpisodeThumbnails = useEpisodeThumbnails, blurNextUp = blurNextUp, @@ -262,6 +265,7 @@ internal fun HomeContinueWatchingSection( BoxWithConstraints(modifier = modifier.fillMaxWidth()) { HomeContinueWatchingSectionContent( items = items, + dataSourceKey = dataSourceKey, style = style, useEpisodeThumbnails = useEpisodeThumbnails, blurNextUp = blurNextUp, @@ -280,6 +284,7 @@ internal fun HomeContinueWatchingSection( @Composable private fun HomeContinueWatchingSectionContent( items: List, + dataSourceKey: WatchProgressSource, style: ContinueWatchingSectionStyle, useEpisodeThumbnails: Boolean, blurNextUp: Boolean, @@ -296,104 +301,63 @@ private fun HomeContinueWatchingSectionContent( HomeCatalogSettingsRepository.uiState }.collectAsStateWithLifecycle() - val disintegration = remember { ContinueWatchingDisintegrationHolder() } - val displayEntries = disintegration.sync(items) + key(dataSourceKey) { + val disintegration = remember { + ScopedDisintegrationTracker( + itemKey = ContinueWatchingItem::videoId, + ) + } + val displayEntries = disintegration.sync(dataSourceKey, items) - NuvioShelfSection( - title = title ?: stringResource(Res.string.compose_settings_page_continue_watching), - entries = displayEntries, - modifier = modifier, - headerHorizontalPadding = sectionPadding, - rowContentPadding = PaddingValues(horizontal = sectionPadding), - itemSpacing = layout.itemGap, - showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline, - key = { entry -> entry.videoId }, - animatePlacement = true, - state = listState, - ) { entry -> - val item = entry.item - val onClick = if (entry.exiting) null else onItemClick?.let { { it(item) } } - val onLongClick = if (entry.exiting) null else onItemLongPress?.let { { it(item) } } - DisintegratingContainer( - disintegrating = entry.exiting, - onDisintegrated = { disintegration.onExited(entry.videoId) }, - ) { - when (style) { - ContinueWatchingSectionStyle.Card -> ContinueWatchingCard( - item = item, - useEpisodeThumbnails = useEpisodeThumbnails, - blurNextUp = blurNextUp, - onClick = onClick, - onLongClick = onLongClick, - ) - ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard( - item = item, - layout = layout, - useEpisodeThumbnails = useEpisodeThumbnails, - blurNextUp = blurNextUp, - onClick = onClick, - onLongClick = onLongClick, - ) - ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard( - item = item, - layout = layout, - useEpisodeThumbnails = useEpisodeThumbnails, - blurNextUp = blurNextUp, - onClick = onClick, - onLongClick = onLongClick, - ) + NuvioShelfSection( + title = title ?: stringResource(Res.string.compose_settings_page_continue_watching), + entries = displayEntries, + modifier = modifier, + headerHorizontalPadding = sectionPadding, + rowContentPadding = PaddingValues(horizontal = sectionPadding), + itemSpacing = layout.itemGap, + showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline, + key = { entry -> entry.key }, + animatePlacement = true, + state = listState, + ) { entry -> + val item = entry.item + val onClick = if (entry.exiting) null else onItemClick?.let { { it(item) } } + val onLongClick = if (entry.exiting) null else onItemLongPress?.let { { it(item) } } + DisintegratingContainer( + disintegrating = entry.exiting, + onDisintegrated = { disintegration.onDisintegrated(entry.key) }, + ) { + when (style) { + ContinueWatchingSectionStyle.Card -> ContinueWatchingCard( + item = item, + useEpisodeThumbnails = useEpisodeThumbnails, + blurNextUp = blurNextUp, + onClick = onClick, + onLongClick = onLongClick, + ) + ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard( + item = item, + layout = layout, + useEpisodeThumbnails = useEpisodeThumbnails, + blurNextUp = blurNextUp, + onClick = onClick, + onLongClick = onLongClick, + ) + ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard( + item = item, + layout = layout, + useEpisodeThumbnails = useEpisodeThumbnails, + blurNextUp = blurNextUp, + onClick = onClick, + onLongClick = onLongClick, + ) + } } } } } -private data class ContinueWatchingDisplayEntry( - val videoId: String, - val item: ContinueWatchingItem, - val exiting: Boolean, -) - -private class ContinueWatchingDisintegrationHolder { - private val exiting = LinkedHashMap>() - private var previous = LinkedHashMap>() - private var invalidations by mutableStateOf(0) - - fun onExited(videoId: String) { - if (exiting.remove(videoId) != null) invalidations++ - } - - fun sync(items: List): List { - @Suppress("UNUSED_EXPRESSION") - invalidations - - val current = LinkedHashMap>() - items.forEachIndexed { index, item -> current[item.videoId] = item to index } - - for ((videoId, info) in previous) { - if (videoId !in current && videoId !in exiting) { - exiting[videoId] = info - } - } - for (videoId in current.keys) { - exiting.remove(videoId) - } - previous = current - - val entries = ArrayList(items.size + exiting.size) - items.forEach { item -> - entries += ContinueWatchingDisplayEntry(item.videoId, item, exiting = false) - } - exiting.entries - .sortedBy { it.value.second } - .forEach { (videoId, info) -> - val insertAt = info.second.coerceIn(0, entries.size) - entries.add(insertAt, ContinueWatchingDisplayEntry(videoId, info.first, exiting = true)) - } - - return entries - } -} - @Composable fun ContinueWatchingStylePreview( style: ContinueWatchingSectionStyle, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt index 618a9a6a..c59cd2a2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt @@ -71,6 +71,7 @@ import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.NuvioViewAllPillSize import com.nuvio.app.core.ui.NuvioShelfSection +import com.nuvio.app.core.ui.ScopedDisintegrationTracker import com.nuvio.app.core.ui.nuvioConsumePointerEvents import com.nuvio.app.features.cloud.CloudLibraryFile import com.nuvio.app.features.cloud.CloudLibraryItem @@ -222,7 +223,11 @@ fun LibraryScreen( uiState.isLoaded && sortedSections.isNotEmpty() ) { - disintegration.sync(sortedSections, LIBRARY_SECTION_PREVIEW_LIMIT) + disintegration.sync( + sourceMode = uiState.sourceMode, + sections = sortedSections, + previewLimit = LIBRARY_SECTION_PREVIEW_LIMIT, + ) } else { disintegration.reset() emptyList() @@ -1196,41 +1201,34 @@ private fun libraryGlobalKey(sectionType: String, item: LibraryItem): String = "$sectionType|${item.type}|${item.id}" private class LibraryDisintegrationHolder { - private val exiting = LinkedHashMap() - private var previous = LinkedHashMap() - private var invalidations by mutableStateOf(0) + private val tracker = ScopedDisintegrationTracker { entry -> + libraryGlobalKey(entry.sectionType, entry.item) + } fun onExited(globalKey: String) { - if (exiting.remove(globalKey) != null) invalidations++ + tracker.onDisintegrated(globalKey) } fun reset() { - exiting.clear() - previous = LinkedHashMap() + tracker.reset() } - fun sync(sections: List, previewLimit: Int): List { - @Suppress("UNUSED_EXPRESSION") - invalidations - - val current = LinkedHashMap() + fun sync( + sourceMode: LibrarySourceMode, + sections: List, + previewLimit: Int, + ): List { + val current = ArrayList() sections.forEach { section -> section.items.take(previewLimit).forEachIndexed { index, item -> - val key = libraryGlobalKey(section.type, item) - current[key] = LibraryExitingEntry(item, section.type, section.displayTitle, index) + current += LibraryExitingEntry(item, section.type, section.displayTitle, index) } } - for ((key, info) in previous) { - if (key !in current && key !in exiting) { - exiting[key] = info - } - } - for (key in current.keys) { - exiting.remove(key) - } - previous = current - - val exitingBySection = exiting.values.groupBy { it.sectionType } + val exitingBySection = tracker.sync(sourceMode, current) + .asSequence() + .filter { entry -> entry.exiting } + .map { entry -> entry.item } + .groupBy { entry -> entry.sectionType } val seenTypes = HashSet(sections.size) val result = ArrayList(sections.size + 1) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt index b7a7a8c0..acdc1adc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt @@ -4,6 +4,7 @@ import com.nuvio.app.features.cloud.CloudLibraryContentType import com.nuvio.app.features.cloud.cloudLibraryProviderPosterUrl import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.tracking.TrackingAttributedItem +import com.nuvio.app.features.tracking.WatchProgressSource import com.nuvio.app.features.watching.domain.WatchingContentRef import kotlinx.serialization.Serializable @@ -131,6 +132,7 @@ data class WatchProgressEntry( } data class WatchProgressUiState( + val source: WatchProgressSource = WatchProgressSource.NUVIO_SYNC, val entries: List = emptyList(), val hasLoadedRemoteProgress: Boolean = false, ) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index c19fabcf..74152187 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -1345,6 +1345,7 @@ object WatchProgressRepository { ?.hasLoadedRemoteProgress ?: hasLoadedNuvioRemoteProgress _uiState.value = WatchProgressUiState( + source = activeSource, entries = sortedEntries, hasLoadedRemoteProgress = hasLoadedRemoteProgress, ) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/ui/ScopedDisintegrationTrackerTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/ui/ScopedDisintegrationTrackerTest.kt new file mode 100644 index 00000000..76a28851 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/ui/ScopedDisintegrationTrackerTest.kt @@ -0,0 +1,49 @@ +package com.nuvio.app.core.ui + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ScopedDisintegrationTrackerTest { + + @Test + fun `same source retains removed items for disintegration`() { + val tracker = ScopedDisintegrationTracker(itemKey = { it }) + tracker.sync(scope = "trakt", items = listOf("first", "second")) + + val entries = tracker.sync(scope = "trakt", items = listOf("second")) + + assertEquals(listOf("first", "second"), entries.map { it.item }) + assertTrue(entries.first { it.item == "first" }.exiting) + assertFalse(entries.first { it.item == "second" }.exiting) + } + + @Test + fun `source replacement drops old items without disintegration`() { + val tracker = ScopedDisintegrationTracker(itemKey = { it }) + tracker.sync(scope = "trakt", items = listOf("trakt-one", "trakt-two")) + tracker.sync(scope = "trakt", items = listOf("trakt-two")) + + val entries = tracker.sync(scope = "simkl", items = listOf("simkl-one")) + + assertEquals(listOf("simkl-one"), entries.map { it.item }) + assertTrue(entries.none { it.exiting }) + + val emptyEntries = tracker.sync(scope = "nuvio-sync", items = emptyList()) + + assertTrue(emptyEntries.isEmpty()) + } + + @Test + fun `reset starts the next snapshot without removals`() { + val tracker = ScopedDisintegrationTracker(itemKey = { it }) + tracker.sync(scope = "trakt", items = listOf("first")) + tracker.reset() + + val entries = tracker.sync(scope = "trakt", items = listOf("second")) + + assertEquals(listOf("second"), entries.map { it.item }) + assertTrue(entries.none { it.exiting }) + } +} From 4f3eaee2cc06e0489c9cbbdba7ac2e5823693cd0 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:02:19 +0530 Subject: [PATCH 48/59] fix(simkl): reconcile watched playback --- .../simkl/SimklPlaybackReconciliation.kt | 26 ++ .../app/features/simkl/SimklProjections.kt | 2 +- .../app/features/simkl/SimklSyncEngine.kt | 4 +- .../simkl/SimklPlaybackReconciliationTest.kt | 283 ++++++++++++++++++ .../app/features/simkl/SimklSyncEngineTest.kt | 74 +++++ 5 files changed, 386 insertions(+), 3 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliation.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliationTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliation.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliation.kt new file mode 100644 index 00000000..8b32da10 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliation.kt @@ -0,0 +1,26 @@ +package com.nuvio.app.features.simkl + +import com.nuvio.app.features.watched.WatchedItem +import com.nuvio.app.features.watchprogress.WatchProgressEntry + +internal fun SimklSyncSnapshot.reconcileWatchedPlayback(): SimklSyncSnapshot { + if (entries.isEmpty() || playback.isEmpty()) return this + val watchedItems = toSimklWatchedProjection().items + if (watchedItems.isEmpty()) return this + val retainedPlayback = playback.filterNot { session -> + session.toWatchProgressEntry()?.let { progress -> + watchedItems.any { watched -> watched.supersedes(progress) } + } == true + } + return if (retainedPlayback.size == playback.size) this else copy(playback = retainedPlayback) +} + +private fun WatchedItem.supersedes(progress: WatchProgressEntry): Boolean { + if (!type.equals(progress.contentType, ignoreCase = true)) return false + if (season != progress.seasonNumber || episode != progress.episodeNumber) return false + val sameProviderItem = trackingProviderItemId + ?.takeIf(String::isNotBlank) + ?.equals(progress.trackingProviderItemId, ignoreCase = true) == true + val sameContent = id.equals(progress.parentMetaId, ignoreCase = true) + return (sameProviderItem || sameContent) && markedAtEpochMs >= progress.lastUpdatedEpochMs +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt index 00b8c7e9..57a42a9c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt @@ -226,7 +226,7 @@ internal fun parseSimklUtcEpochMs(value: String?): Long? { return (((days * 24L + hour) * 60L + minute) * 60L + second) * 1_000L + millis } -private fun SimklPlaybackSession.toWatchProgressEntry(): WatchProgressEntry? { +internal fun SimklPlaybackSession.toWatchProgressEntry(): WatchProgressEntry? { val media = media ?: return null val parentId = media.canonicalContentId() ?: return null val isMovie = mediaType == SimklMediaType.MOVIES 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 index b0dbceb9..24a4f5e8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncEngine.kt @@ -38,7 +38,7 @@ internal class SimklSyncEngine( playback = playback, lastSyncedAtEpochMs = now, lastCheckedAtEpochMs = now, - ) + ).reconcileWatchedPlayback() } private suspend fun initialSync(): SimklSyncSnapshot { @@ -61,7 +61,7 @@ internal class SimklSyncEngine( playback = playback, lastSyncedAtEpochMs = now, lastCheckedAtEpochMs = now, - ) + ).reconcileWatchedPlayback() } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliationTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliationTest.kt new file mode 100644 index 00000000..2ae9450f --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliationTest.kt @@ -0,0 +1,283 @@ +package com.nuvio.app.features.simkl + +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class SimklPlaybackReconciliationTest { + @Test + fun `newer watched episode discards stale playback`() { + val snapshot = episodeSnapshot( + watchedAt = "2024-04-30T22:14:00Z", + pausedAt = "2024-04-30T22:13:00Z", + ) + + assertTrue(snapshot.reconcileWatchedPlayback().playback.isEmpty()) + } + + @Test + fun `equal watched timestamp discards playback`() { + val snapshot = episodeSnapshot( + watchedAt = "2024-04-30T22:13:00Z", + pausedAt = "2024-04-30T22:13:00Z", + ) + + assertTrue(snapshot.reconcileWatchedPlayback().playback.isEmpty()) + } + + @Test + fun `newer playback remains as a rewatch`() { + val snapshot = episodeSnapshot( + watchedAt = "2024-04-30T22:13:00Z", + pausedAt = "2024-04-30T22:14:00Z", + ) + + assertEquals(snapshot.playback, snapshot.reconcileWatchedPlayback().playback) + } + + @Test + fun `watched episode does not discard another episode`() { + val snapshot = SimklSyncSnapshot( + entries = listOf( + watchedEpisodeEntry( + media = media(39687, imdb = "tt4574334"), + season = 1, + episode = 5, + watchedAt = "2024-04-30T22:14:00Z", + ), + ), + playback = listOf( + episodePlayback( + playbackMedia = media(39687, imdb = "tt4574334"), + season = 1, + episode = 4, + pausedAt = "2024-04-30T22:13:00Z", + ), + ), + ) + + assertEquals(snapshot.playback, snapshot.reconcileWatchedPlayback().playback) + } + + @Test + fun `provider identity reconciles different canonical ids`() { + val snapshot = SimklSyncSnapshot( + entries = listOf( + watchedEpisodeEntry( + media = media(39687, imdb = "tt4574334"), + season = 1, + episode = 5, + watchedAt = "2024-04-30T22:14:00Z", + ), + ), + playback = listOf( + episodePlayback( + playbackMedia = media(39687, tvdb = "305288"), + season = 1, + episode = 5, + pausedAt = "2024-04-30T22:13:00Z", + ), + ), + ) + + assertEquals("tvdb:305288", snapshot.playback.single().toWatchProgressEntry()?.parentMetaId) + assertTrue(snapshot.reconcileWatchedPlayback().playback.isEmpty()) + } + + @Test + fun `mapped anime coordinates reconcile the same episode`() { + val animeMedia = media(439744, imdb = "tt2560140") + val snapshot = SimklSyncSnapshot( + entries = listOf( + SimklLibraryEntry( + mediaType = SimklMediaType.ANIME, + status = SimklListStatus.WATCHING, + show = animeMedia, + seasons = listOf( + SimklSeason( + number = 1, + episodes = listOf( + SimklEpisode( + number = 4, + watchedAt = "2024-04-30T22:14:00Z", + tvdb = SimklEpisodeMapping(season = 2, episode = 4), + ), + ), + ), + ), + ), + ), + playback = listOf( + SimklPlaybackSession( + id = 12345, + progress = 62.5, + pausedAt = "2024-04-30T22:13:00Z", + type = "episode", + episode = SimklPlaybackEpisode( + season = 1, + number = 4, + tvdbSeason = 2, + tvdbNumber = 4, + ), + anime = animeMedia, + ), + ), + ) + + assertTrue(snapshot.reconcileWatchedPlayback().playback.isEmpty()) + } + + @Test + fun `newer watched movie discards stale playback`() { + val movieMedia = media(53536, imdb = "tt0181852") + val snapshot = SimklSyncSnapshot( + entries = listOf( + SimklLibraryEntry( + mediaType = SimklMediaType.MOVIES, + status = SimklListStatus.COMPLETED, + lastWatchedAt = "2024-04-30T22:14:00Z", + movie = movieMedia, + ), + ), + playback = listOf( + SimklPlaybackSession( + id = 12345, + progress = 62.5, + pausedAt = "2024-04-30T22:13:00Z", + type = "movie", + movie = movieMedia, + ), + ), + ) + + assertTrue(snapshot.reconcileWatchedPlayback().playback.isEmpty()) + } + + @Test + fun `newer movie playback remains as a rewatch`() { + val movieMedia = media(53536, imdb = "tt0181852") + val snapshot = SimklSyncSnapshot( + entries = listOf( + SimklLibraryEntry( + mediaType = SimklMediaType.MOVIES, + status = SimklListStatus.COMPLETED, + lastWatchedAt = "2024-04-30T22:13:00Z", + movie = movieMedia, + ), + ), + playback = listOf( + SimklPlaybackSession( + id = 12345, + progress = 62.5, + pausedAt = "2024-04-30T22:14:00Z", + type = "movie", + movie = movieMedia, + ), + ), + ) + + assertEquals(snapshot.playback, snapshot.reconcileWatchedPlayback().playback) + } + + @Test + fun `completed series summary does not discard exact episode playback`() { + val showMedia = media(39687, imdb = "tt4574334") + val snapshot = SimklSyncSnapshot( + entries = listOf( + SimklLibraryEntry( + mediaType = SimklMediaType.SHOWS, + status = SimklListStatus.COMPLETED, + lastWatchedAt = "2024-04-30T22:14:00Z", + show = showMedia, + ), + ), + playback = listOf( + episodePlayback( + playbackMedia = showMedia, + season = 1, + episode = 5, + pausedAt = "2024-04-30T22:13:00Z", + ), + ), + ) + + assertEquals(snapshot.playback, snapshot.reconcileWatchedPlayback().playback) + } + + @Test + fun `snapshot without a conflict is returned unchanged`() { + val snapshot = SimklSyncSnapshot(playback = listOf(episodePlayback())) + + assertSame(snapshot, snapshot.reconcileWatchedPlayback()) + } + + private fun episodeSnapshot(watchedAt: String, pausedAt: String): SimklSyncSnapshot { + val showMedia = media(39687, imdb = "tt4574334") + return SimklSyncSnapshot( + entries = listOf( + watchedEpisodeEntry( + media = showMedia, + season = 1, + episode = 5, + watchedAt = watchedAt, + ), + ), + playback = listOf( + episodePlayback( + playbackMedia = showMedia, + season = 1, + episode = 5, + pausedAt = pausedAt, + ), + ), + ) + } + + private fun watchedEpisodeEntry( + media: SimklMedia, + season: Int, + episode: Int, + watchedAt: String, + ): SimklLibraryEntry = SimklLibraryEntry( + mediaType = SimklMediaType.SHOWS, + status = SimklListStatus.WATCHING, + show = media, + seasons = listOf( + SimklSeason( + number = season, + episodes = listOf(SimklEpisode(number = episode, watchedAt = watchedAt)), + ), + ), + ) + + private fun episodePlayback( + playbackMedia: SimklMedia = media(39687, imdb = "tt4574334"), + season: Int = 1, + episode: Int = 5, + pausedAt: String = "2024-04-30T22:13:00Z", + ): SimklPlaybackSession = SimklPlaybackSession( + id = 12345, + progress = 62.5, + pausedAt = pausedAt, + type = "episode", + episode = SimklPlaybackEpisode(season = season, number = episode), + show = playbackMedia, + ) + + private fun media( + id: Long, + imdb: String? = null, + tvdb: String? = null, + ): SimklMedia = SimklMedia( + title = "Title $id", + runtime = 50, + ids = buildJsonObject { + put("simkl", id) + imdb?.let { put("imdb", it) } + tvdb?.let { put("tvdb", it) } + }, + ) +} 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 index f9e0d3ab..0891632a 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSyncEngineTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSyncEngineTest.kt @@ -78,6 +78,60 @@ class SimklSyncEngineTest { assertTrue(remote.isExhausted) } + @Test + fun `watched delta discards retained playback without fetching playback`() = runBlocking { + val retainedPlayback = episodePlayback("1", "2024-04-30T22:13:00Z") + val current = SimklSyncSnapshot( + isInitialized = true, + watermark = "v1", + activities = activities(all = "v1", playback = "p1"), + entries = listOf(entry(SimklMediaType.SHOWS, "1")), + playback = listOf(retainedPlayback), + ) + val remote = ScriptedRemote( + Step.Activities(activities(all = "v2", playback = "p1")), + Step.AllItems( + null, + responseOf(watchedShowEntry("1", "2024-04-30T22:14:00Z")), + ), + ) + + val result = SimklSyncEngine(remote) { 900L }.synchronize(current) + + assertTrue(result.playback.isEmpty()) + assertTrue(result.toSimklProgressEntries().isEmpty()) + assertTrue( + result.toSimklWatchedProjection().items.any { watched -> + watched.season == 1 && watched.episode == 5 + }, + ) + assertEquals( + listOf(SimklAllItemsRequest.Changes("v1")), + remote.allItemsRequests, + ) + assertTrue(remote.isExhausted) + } + + @Test + fun `initial sync discards playback superseded by watched history`() = runBlocking { + val remote = ScriptedRemote( + Step.AllItems( + SimklMediaType.SHOWS, + responseOf(watchedShowEntry("1", "2024-04-30T22:14:00Z")), + ), + Step.AllItems(SimklMediaType.MOVIES, SimklAllItemsResponse(movies = emptyList())), + Step.AllItems(SimklMediaType.ANIME, SimklAllItemsResponse(anime = emptyList())), + Step.Playback(listOf(episodePlayback("1", "2024-04-30T22:13:00Z"))), + Step.Activities(activities(all = "v1", playback = "p1")), + ) + + val result = SimklSyncEngine(remote) { 900L }.synchronize(SimklSyncSnapshot()) + + assertTrue(result.playback.isEmpty()) + assertEquals(1, result.toSimklWatchedProjection().items.size) + assertTrue(remote.isExhausted) + } + @Test fun `delta merge reconciles removals and replaces changed playback atomically`() = runBlocking { val previousActivities = activities(all = "v1", removed = "r1", playback = "p1") @@ -274,6 +328,26 @@ class SimklSyncEngineTest { movie = media(id), ) + fun episodePlayback(id: String, pausedAt: String) = SimklPlaybackSession( + id = id.toLong(), + progress = 62.5, + pausedAt = pausedAt, + type = "episode", + episode = SimklPlaybackEpisode(season = 1, number = 5), + show = media(id), + ) + + fun watchedShowEntry(id: String, watchedAt: String) = + entry(SimklMediaType.SHOWS, id).copy( + lastWatchedAt = watchedAt, + seasons = listOf( + SimklSeason( + number = 1, + episodes = listOf(SimklEpisode(number = 5, watchedAt = watchedAt)), + ), + ), + ) + fun activities( all: String, removed: String = "removed", From 9b278e6cb0ae5d7f305b5ea6c86c960e06f06037 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:27:27 +0530 Subject: [PATCH 49/59] fix(simkl): refresh watch state after manual sync --- .../settings/TrackingProviderCards.kt | 26 +++--- .../app/features/simkl/SimklSyncRepository.kt | 14 ++- .../TrackingProviderRefreshCoordinator.kt | 14 +++ .../WatchProgressSourceCoordinator.kt | 16 ++++ .../features/simkl/SimklRefreshPolicyTest.kt | 38 ++++++++ .../TrackingProviderRefreshCoordinatorTest.kt | 87 +++++++++++++++++++ 6 files changed, 181 insertions(+), 14 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/TrackingProviderRefreshCoordinator.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/TrackingProviderRefreshCoordinatorTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingProviderCards.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingProviderCards.kt index de086fbd..51114188 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingProviderCards.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingProviderCards.kt @@ -57,6 +57,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.ui.NuvioLoadingIndicator import com.nuvio.app.core.ui.NuvioTokens import com.nuvio.app.core.ui.nuvio +import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.simkl.SimklAuthError import com.nuvio.app.features.simkl.SimklAuthRepository import com.nuvio.app.features.simkl.SimklAuthUiState @@ -64,12 +65,14 @@ import com.nuvio.app.features.simkl.SimklBrandAsset import com.nuvio.app.features.simkl.SimklConnectionMode import com.nuvio.app.features.simkl.SimklSyncRepository import com.nuvio.app.features.simkl.simklBrandPainter +import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.tracking.TrackingRefreshIntent import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktAuthUiState import com.nuvio.app.features.trakt.TraktBrandAsset import com.nuvio.app.features.trakt.TraktConnectionMode import com.nuvio.app.features.trakt.traktBrandPainter +import com.nuvio.app.features.watchprogress.WatchProgressSourceCoordinator import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_cancel @@ -156,6 +159,17 @@ internal fun TrackingProviderCards( }.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() var showSyncInfo by rememberSaveable { mutableStateOf(false) } + val onSimklSyncRequested: () -> Unit = { + scope.launch { + WatchProgressSourceCoordinator.refreshProviderAndActiveSource( + profileId = ProfileRepository.activeProfileId, + providerId = TrackingProviderId.SIMKL, + refreshProvider = { + SimklSyncRepository.refresh(TrackingRefreshIntent.USER_INITIATED) + }, + ) + } + } BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { val useTwoColumns = maxWidth >= 600.dp @@ -176,11 +190,7 @@ internal fun TrackingProviderCards( uiState = simklUiState, isSyncing = syncState.isLoading, syncErrorMessage = syncState.errorMessage, - onSyncRequested = { - scope.launch { - SimklSyncRepository.refresh(TrackingRefreshIntent.USER_INITIATED) - } - }, + onSyncRequested = onSimklSyncRequested, onInfoRequested = { showSyncInfo = true }, modifier = Modifier .weight(1f) @@ -200,11 +210,7 @@ internal fun TrackingProviderCards( uiState = simklUiState, isSyncing = syncState.isLoading, syncErrorMessage = syncState.errorMessage, - onSyncRequested = { - scope.launch { - SimklSyncRepository.refresh(TrackingRefreshIntent.USER_INITIATED) - } - }, + onSyncRequested = onSimklSyncRequested, onInfoRequested = { showSyncInfo = true }, modifier = Modifier.fillMaxWidth(), ) 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 index 0045f606..59772d6c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt @@ -73,23 +73,23 @@ object SimklSyncRepository : TrackingProfileStore { scope.launch { refresh(intent, origin) } } - suspend fun refresh(intent: TrackingRefreshIntent) { + suspend fun refresh(intent: TrackingRefreshIntent): Boolean = refresh(intent, SimklRefreshOrigin.MANUAL_SYNC) - } internal suspend fun refresh( intent: TrackingRefreshIntent, origin: SimklRefreshOrigin, - ) { + ): Boolean { ensureLoaded() val requestId = refreshRequestSequence.incrementAndGet() val requestedGeneration = profileGeneration + val requestedProfileId = ProfileRepository.activeProfileId val before = _state.value SimklWatchDiagnostics.logRefreshRequest( requestId = requestId, origin = origin, intent = intent, - profileId = ProfileRepository.activeProfileId, + profileId = requestedProfileId, profileGeneration = requestedGeneration, authenticated = SimklAuthRepository.isAuthenticated.value, snapshot = before.snapshot, @@ -132,6 +132,12 @@ object SimklSyncRepository : TrackingProfileStore { before = before, after = _state.value, ) + val completed = _state.value + return requestedGeneration == profileGeneration && + requestedProfileId == ProfileRepository.activeProfileId && + SimklAuthRepository.isAuthenticated.value && + completed.hasLoaded && + completed.errorMessage == null } private suspend fun refreshSnapshot(generation: Long) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/TrackingProviderRefreshCoordinator.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/TrackingProviderRefreshCoordinator.kt new file mode 100644 index 00000000..f886610e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/TrackingProviderRefreshCoordinator.kt @@ -0,0 +1,14 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.features.tracking.TrackingProviderId + +internal suspend fun coordinateTrackingProviderRefresh( + providerId: TrackingProviderId, + refreshProvider: suspend () -> Boolean, + activeProviderId: () -> TrackingProviderId?, + refreshActiveReadModels: suspend () -> Boolean, +): Boolean { + if (!refreshProvider()) return false + if (activeProviderId() != providerId) return true + return refreshActiveReadModels() +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt index 224a1746..bd14273c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressSourceCoordinator.kt @@ -309,6 +309,22 @@ object WatchProgressSourceCoordinator { } } + suspend fun refreshProviderAndActiveSource( + profileId: Int, + providerId: TrackingProviderId, + refreshProvider: suspend () -> Boolean, + ): Boolean = coordinateTrackingProviderRefresh( + providerId = providerId, + refreshProvider = refreshProvider, + activeProviderId = { + ensureSourceStateLoaded() + currentContext(profileId).effectiveSource.providerId + }, + refreshActiveReadModels = { + refreshActiveSource(profileId = profileId, force = false).succeeded + }, + ) + private fun ensureSourceStateLoadedForGeneration(expectedGeneration: Long) { synchronized(startLock) { ensureCoordinatorGeneration(expectedGeneration) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt index 11e59588..6a03d0a2 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt @@ -152,6 +152,44 @@ class SimklRefreshPolicyTest { assertEquals(1, executions) } + @Test + fun `manual sync followed by read model refresh executes once`() = runBlocking { + val gate = SimklRefreshGate() + var lastCheckedAtEpochMs: Long? = null + var executions = 0 + + val outcomes = listOf( + TrackingRefreshIntent.USER_INITIATED, + TrackingRefreshIntent.AUTOMATIC, + TrackingRefreshIntent.AUTOMATIC, + ).map { intent -> + gate.runIfNeeded( + profileGeneration = 7L, + shouldRun = { + shouldRunSimklRefresh( + intent = intent, + lastCheckedAtEpochMs = lastCheckedAtEpochMs, + nowEpochMs = 1_000L, + hasError = false, + ) + }, + ) { + executions += 1 + lastCheckedAtEpochMs = 1_000L + } + } + + assertEquals( + listOf( + SimklRefreshGateOutcome.EXECUTED, + SimklRefreshGateOutcome.FRESHNESS_SKIPPED, + SimklRefreshGateOutcome.FRESHNESS_SKIPPED, + ), + outcomes, + ) + assertEquals(1, executions) + } + @Test fun `mutation invalidation still refreshes after startup check`() = runBlocking { val gate = SimklRefreshGate() diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/TrackingProviderRefreshCoordinatorTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/TrackingProviderRefreshCoordinatorTest.kt new file mode 100644 index 00000000..7ffb9bcb --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/TrackingProviderRefreshCoordinatorTest.kt @@ -0,0 +1,87 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.features.tracking.TrackingProviderId +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TrackingProviderRefreshCoordinatorTest { + @Test + fun `successful active provider refresh republishes read models in order`() = runBlocking { + val events = mutableListOf() + + val result = coordinateTrackingProviderRefresh( + providerId = TrackingProviderId.SIMKL, + refreshProvider = { + events += "provider" + true + }, + activeProviderId = { + events += "source" + TrackingProviderId.SIMKL + }, + refreshActiveReadModels = { + events += "read-models" + true + }, + ) + + assertTrue(result) + assertEquals(listOf("provider", "source", "read-models"), events) + } + + @Test + fun `inactive provider refresh does not refresh another source`() = runBlocking { + var readModelRefreshes = 0 + + val result = coordinateTrackingProviderRefresh( + providerId = TrackingProviderId.SIMKL, + refreshProvider = { true }, + activeProviderId = { TrackingProviderId.TRAKT }, + refreshActiveReadModels = { + readModelRefreshes += 1 + true + }, + ) + + assertTrue(result) + assertEquals(0, readModelRefreshes) + } + + @Test + fun `failed provider refresh does not publish read models`() = runBlocking { + var sourceReads = 0 + var readModelRefreshes = 0 + + val result = coordinateTrackingProviderRefresh( + providerId = TrackingProviderId.SIMKL, + refreshProvider = { false }, + activeProviderId = { + sourceReads += 1 + TrackingProviderId.SIMKL + }, + refreshActiveReadModels = { + readModelRefreshes += 1 + true + }, + ) + + assertFalse(result) + assertEquals(0, sourceReads) + assertEquals(0, readModelRefreshes) + } + + @Test + fun `read model refresh failure is surfaced`() = runBlocking { + val result = coordinateTrackingProviderRefresh( + providerId = TrackingProviderId.SIMKL, + refreshProvider = { true }, + activeProviderId = { TrackingProviderId.SIMKL }, + refreshActiveReadModels = { false }, + ) + + assertFalse(result) + } +} From a69b7efd8b2747785f1c49aadbe9f416785fc792 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:25:51 +0530 Subject: [PATCH 50/59] fix(simkl): gate sync reads by activity --- .../app/features/simkl/SimklSyncEngine.kt | 43 ++++- .../app/features/simkl/SimklSyncEngineTest.kt | 150 +++++++++++++++++- 2 files changed, 183 insertions(+), 10 deletions(-) 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 index 24a4f5e8..9676f854 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncEngine.kt @@ -16,8 +16,11 @@ internal class SimklSyncEngine( } if (current.watermark == null) return initialSync() - val delta = remote.fetchAllItems(SimklAllItemsRequest.Changes(current.watermark)) - var entries = mergeDelta(current.entries, delta) + var entries = current.entries + if (hasAllItemsActivityChanged(current.activities, activities)) { + val delta = remote.fetchAllItems(SimklAllItemsRequest.Changes(current.watermark)) + entries = mergeDelta(entries, delta) + } if (hasRemovalActivityChanged(current.activities, activities)) { val authoritativeIds = remote.fetchAllItems(SimklAllItemsRequest.CurrentIds) @@ -89,19 +92,43 @@ internal fun reconcileRemovedEntries( .sortedWith(simklEntryComparator) } +private fun hasAllItemsActivityChanged( + previous: SimklActivities?, + current: SimklActivities, +): Boolean { + if (previous == null) return true + return SimklMediaType.entries.any { type -> + current.domain(type).hasAllItemsActivityChangedFrom(previous.domain(type)) + } +} + +private fun SimklActivityDomain.hasAllItemsActivityChangedFrom( + previous: SimklActivityDomain, +): Boolean = + ratedAt != previous.ratedAt || + plantowatch != previous.plantowatch || + watching != previous.watching || + completed != previous.completed || + hold != previous.hold || + dropped != previous.dropped + private fun hasRemovalActivityChanged( previous: SimklActivities?, current: SimklActivities, -): Boolean = SimklMediaType.entries.any { type -> - previous?.domain(type)?.removedFromList != current.domain(type).removedFromList -} +): Boolean = + previous == null || + 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 -} +): Boolean = + previous == null || + SimklMediaType.entries.any { type -> + previous.domain(type).playback != current.domain(type).playback + } private val simklEntryComparator = compareBy( { entry -> entry.mediaType.ordinal }, 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 index 0891632a..74836c1d 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSyncEngineTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSyncEngineTest.kt @@ -78,6 +78,138 @@ class SimklSyncEngineTest { assertTrue(remote.isExhausted) } + @Test + fun `playback only activity refreshes playback without all items`() = runBlocking { + val current = SimklSyncSnapshot( + isInitialized = true, + watermark = "v1", + activities = activities(all = "v1", playback = "p1", library = "l1"), + entries = listOf(entry(SimklMediaType.SHOWS, "1")), + playback = listOf(playback("1")), + ) + val remote = ScriptedRemote( + Step.Activities(activities(all = "v2", playback = "p2", library = "l1")), + Step.Playback(listOf(playback("2"))), + ) + + val result = SimklSyncEngine(remote) { 900L }.synchronize(current) + + assertEquals(current.entries, result.entries) + assertEquals("2", result.playback.single().media?.ids?.idValue("simkl")) + assertEquals("v2", result.watermark) + assertTrue(remote.allItemsRequests.isEmpty()) + assertTrue(remote.isExhausted) + } + + @Test + fun `settings only activity updates watermark without projection calls`() = runBlocking { + val current = SimklSyncSnapshot( + isInitialized = true, + watermark = "v1", + activities = activities(all = "v1", library = "l1", settings = "s1"), + entries = listOf(entry(SimklMediaType.SHOWS, "1")), + playback = listOf(playback("1")), + ) + val remote = ScriptedRemote( + Step.Activities(activities(all = "v2", library = "l1", settings = "s2")), + ) + + val result = SimklSyncEngine(remote) { 900L }.synchronize(current) + + assertEquals(current.entries, result.entries) + assertEquals(current.playback, result.playback) + assertEquals("s2", result.activities?.settings?.all) + assertEquals("v2", result.watermark) + assertTrue(remote.allItemsRequests.isEmpty()) + assertTrue(remote.isExhausted) + } + + @Test + fun `removal only activity reconciles ids without delta`() = runBlocking { + val retained = entry(SimklMediaType.SHOWS, "1") + val current = SimklSyncSnapshot( + isInitialized = true, + watermark = "v1", + activities = activities(all = "v1", removed = "r1", library = "l1"), + entries = listOf( + retained, + entry(SimklMediaType.MOVIES, "2", SimklListStatus.PLAN_TO_WATCH), + ), + ) + val authoritative = SimklAllItemsResponse( + shows = listOf(retained), + movies = emptyList(), + anime = emptyList(), + ) + val remote = ScriptedRemote( + Step.Activities(activities(all = "v2", removed = "r2", library = "l1")), + Step.AllItems(null, authoritative), + ) + + val result = SimklSyncEngine(remote) { 900L }.synchronize(current) + + assertEquals(listOf("1"), result.entries.mapNotNull { it.media?.ids?.idValue("simkl") }) + assertEquals( + listOf(SimklAllItemsRequest.CurrentIds), + remote.allItemsRequests, + ) + assertTrue(remote.isExhausted) + } + + @Test + fun `each library activity timestamp requests an all items delta`() = runBlocking { + val previousDomain = SimklActivityDomain( + all = "d1", + ratedAt = "rated1", + playback = "playback", + plantowatch = "plantowatch1", + watching = "watching1", + completed = "completed1", + hold = "hold1", + dropped = "dropped1", + removedFromList = "removed", + ) + val changes = listOf( + "rated_at" to previousDomain.copy(ratedAt = "rated2"), + "plantowatch" to previousDomain.copy(plantowatch = "plantowatch2"), + "watching" to previousDomain.copy(watching = "watching2"), + "completed" to previousDomain.copy(completed = "completed2"), + "hold" to previousDomain.copy(hold = "hold2"), + "dropped" to previousDomain.copy(dropped = "dropped2"), + ) + + changes.forEach { (name, changedDomain) -> + val previousActivities = SimklActivities( + all = "v1", + tvShows = previousDomain, + movies = previousDomain, + anime = previousDomain, + ) + val changedActivities = previousActivities.copy( + all = "v2", + tvShows = changedDomain.copy(all = "d2"), + ) + val current = SimklSyncSnapshot( + isInitialized = true, + watermark = "v1", + activities = previousActivities, + ) + val remote = ScriptedRemote( + Step.Activities(changedActivities), + Step.AllItems(null, SimklAllItemsResponse()), + ) + + SimklSyncEngine(remote) { 900L }.synchronize(current) + + assertEquals( + listOf(SimklAllItemsRequest.Changes("v1")), + remote.allItemsRequests, + name, + ) + assertTrue(remote.isExhausted, name) + } + } + @Test fun `watched delta discards retained playback without fetching playback`() = runBlocking { val retainedPlayback = episodePlayback("1", "2024-04-30T22:13:00Z") @@ -352,13 +484,27 @@ class SimklSyncEngineTest { all: String, removed: String = "removed", playback: String = "playback", + library: String = all, + settings: String = "settings", ): SimklActivities { val domain = SimklActivityDomain( all = all, - removedFromList = removed, + ratedAt = library, playback = playback, + plantowatch = library, + watching = library, + completed = library, + hold = library, + dropped = library, + removedFromList = removed, + ) + return SimklActivities( + all = all, + settings = SimklActivitySettings(all = settings), + tvShows = domain, + movies = domain, + anime = domain, ) - return SimklActivities(all = all, tvShows = domain, movies = domain, anime = domain) } const val ALL_ITEMS_FIXTURE = """ From 94ec538c1002ce7f3293c50eed3836e9c1a58424 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:09:09 +0530 Subject: [PATCH 51/59] fix(simkl): reconcile completed and dropped progress --- .../com/nuvio/app/features/home/HomeScreen.kt | 11 +-- .../simkl/SimklApplicationAdapters.kt | 3 + .../simkl/SimklPlaybackReconciliation.kt | 41 +++++++++- .../app/features/simkl/SimklProjections.kt | 4 +- .../nuvio/app/features/home/HomeScreenTest.kt | 29 +++++++ .../simkl/SimklPlaybackReconciliationTest.kt | 76 ++++++++++++++++++- 6 files changed, 153 insertions(+), 11 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index a22b9d31..9416828a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -212,16 +212,14 @@ fun HomeScreen( progressProviderOwnsCompletedHistory, continueWatchingPreferences.upNextFromFurthestEpisode, ) { - val visibleProviderEntries = watchProgressUiState.entries.filterNot { entry -> - WatchProgressRepository.isDroppedShow(entry.parentMetaId) - } buildHomeNextUpSeedCandidates( - progressEntries = visibleProviderEntries, + progressEntries = watchProgressUiState.entries, watchedItems = nextUpWatchedItems, providerOwnsCompletedHistory = progressProviderOwnsCompletedHistory, preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, nowEpochMs = WatchProgressClock.nowEpochMs(), shouldUseProgressSeed = WatchProgressRepository::shouldUseAsNextUpSeed, + isContentHidden = WatchProgressRepository::isDroppedShow, ) } @@ -1192,9 +1190,11 @@ internal fun buildHomeNextUpSeedCandidates( shouldUseProgressSeed: (WatchProgressEntry, Long) -> Boolean = { entry, _ -> entry.shouldUseAsCompletedSeedForContinueWatching() }, + isContentHidden: (String) -> Boolean = { false }, ): List { val progressSeeds = progressEntries .asSequence() + .filterNot { entry -> isContentHidden(entry.parentMetaId) } .filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() } .filter { entry -> entry.seasonNumber != null && entry.episodeNumber != null && entry.seasonNumber != 0 } .filter { entry -> !isMalformedNextUpSeedContentId(entry.parentMetaId) } @@ -1204,7 +1204,8 @@ internal fun buildHomeNextUpSeedCandidates( emptyList() } else { watchedItems.filter { item -> - item.type.isSeriesTypeForContinueWatching() && + !isContentHidden(item.id) && + item.type.isSeriesTypeForContinueWatching() && item.season != null && item.episode != null && item.season != 0 && diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt index 1ae732ba..ed989814 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt @@ -204,6 +204,9 @@ object SimklTrackingProgressProvider : TrackingProgressProvider { override suspend fun removeProgress(entries: Collection) = SimklProgressRepository.removeProgress(entries) + + override fun isHiddenFromProgress(contentId: String): Boolean = + SimklSyncRepository.state.value.snapshot.isDroppedContent(contentId) } private const val SIMKL_PLAYBACK_PROGRESS_KEY_PREFIX = "simkl-playback:" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliation.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliation.kt index 8b32da10..cbcd73c5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliation.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliation.kt @@ -4,17 +4,23 @@ import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watchprogress.WatchProgressEntry internal fun SimklSyncSnapshot.reconcileWatchedPlayback(): SimklSyncSnapshot { - if (entries.isEmpty() || playback.isEmpty()) return this + if (playback.isEmpty()) return this val watchedItems = toSimklWatchedProjection().items - if (watchedItems.isEmpty()) return this val retainedPlayback = playback.filterNot { session -> session.toWatchProgressEntry()?.let { progress -> - watchedItems.any { watched -> watched.supersedes(progress) } + entries.any { entry -> entry.hidesPlayback(progress) } || + watchedItems.any { watched -> watched.supersedes(progress) } } == true } return if (retainedPlayback.size == playback.size) this else copy(playback = retainedPlayback) } +internal fun SimklSyncSnapshot.isDroppedContent(contentId: String): Boolean = + entries.any { entry -> + entry.status == SimklListStatus.DROPPED && + entry.matchesContent(contentId = contentId, trackingProviderItemId = null) + } + private fun WatchedItem.supersedes(progress: WatchProgressEntry): Boolean { if (!type.equals(progress.contentType, ignoreCase = true)) return false if (season != progress.seasonNumber || episode != progress.episodeNumber) return false @@ -24,3 +30,32 @@ private fun WatchedItem.supersedes(progress: WatchProgressEntry): Boolean { val sameContent = id.equals(progress.parentMetaId, ignoreCase = true) return (sameProviderItem || sameContent) && markedAtEpochMs >= progress.lastUpdatedEpochMs } + +private fun SimklLibraryEntry.hidesPlayback(progress: WatchProgressEntry): Boolean { + if ( + !matchesContent( + contentId = progress.parentMetaId, + trackingProviderItemId = progress.trackingProviderItemId, + ) + ) { + return false + } + return when (status) { + SimklListStatus.DROPPED -> true + SimklListStatus.COMPLETED -> + parseSimklUtcEpochMs(lastWatchedAt)?.let { completedAt -> + completedAt >= progress.lastUpdatedEpochMs + } == true + else -> false + } +} + +private fun SimklLibraryEntry.matchesContent( + contentId: String, + trackingProviderItemId: String?, +): Boolean { + val providerItemId = media?.simklTrackingProviderItemId() + val sameProviderItem = providerItemId != null && + providerItemId.equals(trackingProviderItemId, ignoreCase = true) + return sameProviderItem || matchesContentId(contentId) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt index 57a42a9c..f699e6fe 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt @@ -272,10 +272,10 @@ internal fun SimklPlaybackSession.toWatchProgressEntry(): WatchProgressEntry? { ) } -private fun SimklMedia.simklTrackingProviderItemId(): String? = +internal fun SimklMedia.simklTrackingProviderItemId(): String? = ids.simklIdValue()?.toLongOrNull()?.takeIf { it > 0L }?.let { id -> "simkl:$id" } -private fun SimklLibraryEntry.matchesContentId(contentId: String): Boolean { +internal fun SimklLibraryEntry.matchesContentId(contentId: String): Boolean { val media = media ?: return false if (media.canonicalContentId().equals(contentId, ignoreCase = true)) return true val parsed = parseTrackingExternalIds(contentId) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt index 2a70a4c1..d87394ac 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt @@ -564,6 +564,35 @@ class HomeScreenTest { assertEquals(listOf("show"), result.map { it.content.id }) } + @Test + fun `hidden provider content cannot seed next up from progress or watched history`() { + val progress = progressEntry( + videoId = "dropped-show:1:2", + title = "Dropped Show", + seasonNumber = 1, + episodeNumber = 2, + lastUpdatedEpochMs = 2_000L, + isCompleted = true, + ) + val watched = watchedItem( + id = "dropped-show", + season = 1, + episode = 2, + markedAtEpochMs = 2_000L, + ) + + val result = buildHomeNextUpSeedCandidates( + progressEntries = listOf(progress), + watchedItems = listOf(watched), + providerOwnsCompletedHistory = false, + preferFurthestEpisode = true, + nowEpochMs = 3_000L, + isContentHidden = { contentId -> contentId == "dropped-show" }, + ) + + assertTrue(result.isEmpty()) + } + @Test fun `stale live next up item is dropped when current seed advances`() { val staleNextUp = continueWatchingItem( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliationTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliationTest.kt index 2ae9450f..fe5dab14 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliationTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliationTest.kt @@ -4,6 +4,7 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertSame import kotlin.test.assertTrue @@ -183,7 +184,7 @@ class SimklPlaybackReconciliationTest { } @Test - fun `completed series summary does not discard exact episode playback`() { + fun `newer completed series summary discards stale episode playback`() { val showMedia = media(39687, imdb = "tt4574334") val snapshot = SimklSyncSnapshot( entries = listOf( @@ -204,9 +205,82 @@ class SimklPlaybackReconciliationTest { ), ) + assertTrue(snapshot.reconcileWatchedPlayback().playback.isEmpty()) + } + + @Test + fun `newer episode playback remains after completed series summary`() { + val showMedia = media(39687, imdb = "tt4574334") + val snapshot = SimklSyncSnapshot( + entries = listOf( + SimklLibraryEntry( + mediaType = SimklMediaType.SHOWS, + status = SimklListStatus.COMPLETED, + lastWatchedAt = "2024-04-30T22:13:00Z", + show = showMedia, + ), + ), + playback = listOf( + episodePlayback( + playbackMedia = showMedia, + season = 1, + episode = 5, + pausedAt = "2024-04-30T22:14:00Z", + ), + ), + ) + assertEquals(snapshot.playback, snapshot.reconcileWatchedPlayback().playback) } + @Test + fun `dropped series discards playback using provider identity`() { + val snapshot = SimklSyncSnapshot( + entries = listOf( + SimklLibraryEntry( + mediaType = SimklMediaType.SHOWS, + status = SimklListStatus.DROPPED, + show = media(39687, imdb = "tt4574334"), + ), + ), + playback = listOf( + episodePlayback( + playbackMedia = media(39687, tvdb = "305288"), + season = 1, + episode = 5, + pausedAt = "2024-04-30T22:14:00Z", + ), + ), + ) + + assertTrue(snapshot.reconcileWatchedPlayback().playback.isEmpty()) + assertTrue(snapshot.isDroppedContent("tt4574334")) + } + + @Test + fun `different dropped series does not discard playback`() { + val snapshot = SimklSyncSnapshot( + entries = listOf( + SimklLibraryEntry( + mediaType = SimklMediaType.SHOWS, + status = SimklListStatus.DROPPED, + show = media(11111, imdb = "tt1111111"), + ), + ), + playback = listOf( + episodePlayback( + playbackMedia = media(39687, imdb = "tt4574334"), + season = 1, + episode = 5, + pausedAt = "2024-04-30T22:14:00Z", + ), + ), + ) + + assertEquals(snapshot.playback, snapshot.reconcileWatchedPlayback().playback) + assertFalse(snapshot.isDroppedContent("tt4574334")) + } + @Test fun `snapshot without a conflict is returned unchanged`() { val snapshot = SimklSyncSnapshot(playback = listOf(episodePlayback())) From 327d49d31df7f58b1fa39db87cd58714cf053430 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:30:41 +0530 Subject: [PATCH 52/59] fix(simkl): refresh hidden progress immediately --- .../com/nuvio/app/features/home/HomeScreen.kt | 27 +++++++++++++++---- .../simkl/SimklApplicationAdapters.kt | 1 + .../simkl/SimklPlaybackReconciliation.kt | 6 +++++ .../app/features/tracking/TrackingReads.kt | 1 + .../watchprogress/WatchProgressModels.kt | 1 + .../watchprogress/WatchProgressRepository.kt | 24 ++++++++++++----- .../simkl/SimklPlaybackReconciliationTest.kt | 2 ++ .../WatchProgressIdentityTest.kt | 27 +++++++++++++++++++ 8 files changed, 78 insertions(+), 11 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 9416828a..547f2a6d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -195,10 +195,12 @@ fun HomeScreen( val effectiveWatchProgressEntries = remember( watchProgressUiState.entries, + watchProgressUiState.hiddenContentIds, continueWatchingCutoffEpochMs, ) { val visibleProviderEntries = watchProgressUiState.entries.filterNot { entry -> - WatchProgressRepository.isDroppedShow(entry.parentMetaId) + entry.parentMetaId in watchProgressUiState.hiddenContentIds || + WatchProgressRepository.isDroppedShow(entry.parentMetaId) } filterEntriesForContinueWatchingWindow( entries = visibleProviderEntries, @@ -208,6 +210,7 @@ fun HomeScreen( val allNextUpSeedCandidates = remember( watchProgressUiState.entries, + watchProgressUiState.hiddenContentIds, nextUpWatchedItems, progressProviderOwnsCompletedHistory, continueWatchingPreferences.upNextFromFurthestEpisode, @@ -219,7 +222,10 @@ fun HomeScreen( preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, nowEpochMs = WatchProgressClock.nowEpochMs(), shouldUseProgressSeed = WatchProgressRepository::shouldUseAsNextUpSeed, - isContentHidden = WatchProgressRepository::isDroppedShow, + isContentHidden = { contentId -> + contentId in watchProgressUiState.hiddenContentIds || + WatchProgressRepository.isDroppedShow(contentId) + }, ) } @@ -346,6 +352,7 @@ fun HomeScreen( nextUpItemsBySeries, continueWatchingPreferences.showUnairedNextUp, watchedUiState.isLoaded, + watchProgressUiState.hiddenContentIds, ) { cachedSnapshots.first.mapNotNull { cached -> if ( @@ -382,7 +389,10 @@ fun HomeScreen( if (!cachedNextUpHasAired(cached) && !continueWatchingPreferences.showUnairedNextUp) { return@mapNotNull null } - if (WatchProgressRepository.isDroppedShow(cached.contentId)) { + if ( + cached.contentId in watchProgressUiState.hiddenContentIds || + WatchProgressRepository.isDroppedShow(cached.contentId) + ) { return@mapNotNull null } val item = cached.toContinueWatchingItem() ?: return@mapNotNull null @@ -394,9 +404,16 @@ fun HomeScreen( cached.contentId to (sortTimestamp to item) }.toMap() } - val cachedInProgressItems = remember(cachedSnapshots.second, effectiveWatchProgressSource) { + val cachedInProgressItems = remember( + cachedSnapshots.second, + effectiveWatchProgressSource, + watchProgressUiState.hiddenContentIds, + ) { cachedSnapshots.second.mapNotNull { cached -> - if (WatchProgressRepository.isDroppedShow(cached.contentId)) { + if ( + cached.contentId in watchProgressUiState.hiddenContentIds || + WatchProgressRepository.isDroppedShow(cached.contentId) + ) { return@mapNotNull null } cached.resolvedProgressKey() to cached.toContinueWatchingItem() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt index ed989814..11f44e84 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt @@ -197,6 +197,7 @@ object SimklTrackingProgressProvider : TrackingProgressProvider { val state = SimklProgressRepository.uiState.value return TrackingProgressSnapshot( entries = state.entries, + hiddenContentIds = SimklSyncRepository.state.value.snapshot.droppedContentIds(), hasLoadedRemoteProgress = state.hasLoadedRemoteProgress, errorMessage = state.errorMessage, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliation.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliation.kt index cbcd73c5..baebb3c2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliation.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliation.kt @@ -21,6 +21,12 @@ internal fun SimklSyncSnapshot.isDroppedContent(contentId: String): Boolean = entry.matchesContent(contentId = contentId, trackingProviderItemId = null) } +internal fun SimklSyncSnapshot.droppedContentIds(): Set = + entries.asSequence() + .filter { entry -> entry.status == SimklListStatus.DROPPED } + .mapNotNull { entry -> entry.media?.canonicalContentId() } + .toSet() + private fun WatchedItem.supersedes(progress: WatchProgressEntry): Boolean { if (!type.equals(progress.contentType, ignoreCase = true)) return false if (season != progress.seasonNumber || episode != progress.episodeNumber) return false diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt index f0c500c8..8adc3110 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt @@ -110,6 +110,7 @@ interface TrackingWatchedProvider : WatchedSyncAdapter { data class TrackingProgressSnapshot( val entries: List = emptyList(), + val hiddenContentIds: Set = emptySet(), val hasLoadedRemoteProgress: Boolean = false, val errorMessage: String? = null, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt index acdc1adc..cb7ee8d4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt @@ -134,6 +134,7 @@ data class WatchProgressEntry( data class WatchProgressUiState( val source: WatchProgressSource = WatchProgressSource.NUVIO_SYNC, val entries: List = emptyList(), + val hiddenContentIds: Set = emptySet(), val hasLoadedRemoteProgress: Boolean = false, ) { val byProgressKey: Map diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index 74152187..1c19e3f8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -13,6 +13,7 @@ import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.player.PlayerPlaybackSnapshot import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.tracking.TrackingProgressProvider +import com.nuvio.app.features.tracking.TrackingProgressSnapshot import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.tracking.TrackingProviderRegistry import com.nuvio.app.features.tracking.TrackingSettingsRepository @@ -1340,14 +1341,12 @@ object WatchProgressRepository { private fun publish() { val entries = currentEntries() val sortedEntries = entries.sortedByDescending { it.lastUpdatedEpochMs } - val hasLoadedRemoteProgress = activeProgressProvider() - ?.snapshot() - ?.hasLoadedRemoteProgress - ?: hasLoadedNuvioRemoteProgress - _uiState.value = WatchProgressUiState( + val providerSnapshot = activeProgressProvider()?.snapshot() + _uiState.value = projectWatchProgressUiState( source = activeSource, entries = sortedEntries, - hasLoadedRemoteProgress = hasLoadedRemoteProgress, + providerSnapshot = providerSnapshot, + hasLoadedNuvioRemoteProgress = hasLoadedNuvioRemoteProgress, ) } @@ -1643,3 +1642,16 @@ object WatchProgressRepository { resources.any { resource -> resource.name == "meta" } } + +internal fun projectWatchProgressUiState( + source: WatchProgressSource, + entries: List, + providerSnapshot: TrackingProgressSnapshot?, + hasLoadedNuvioRemoteProgress: Boolean, +): WatchProgressUiState = WatchProgressUiState( + source = source, + entries = entries, + hiddenContentIds = providerSnapshot?.hiddenContentIds.orEmpty(), + hasLoadedRemoteProgress = + providerSnapshot?.hasLoadedRemoteProgress ?: hasLoadedNuvioRemoteProgress, +) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliationTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliationTest.kt index fe5dab14..1253b1fb 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliationTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliationTest.kt @@ -255,6 +255,7 @@ class SimklPlaybackReconciliationTest { assertTrue(snapshot.reconcileWatchedPlayback().playback.isEmpty()) assertTrue(snapshot.isDroppedContent("tt4574334")) + assertEquals(setOf("tt4574334"), snapshot.droppedContentIds()) } @Test @@ -279,6 +280,7 @@ class SimklPlaybackReconciliationTest { assertEquals(snapshot.playback, snapshot.reconcileWatchedPlayback().playback) assertFalse(snapshot.isDroppedContent("tt4574334")) + assertEquals(setOf("tt1111111"), snapshot.droppedContentIds()) } @Test diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt index 39003816..4ddb5a6f 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt @@ -1,13 +1,40 @@ package com.nuvio.app.features.watchprogress import com.nuvio.app.features.details.MetaDetails +import com.nuvio.app.features.tracking.TrackingProgressSnapshot +import com.nuvio.app.features.tracking.WatchProgressSource import com.nuvio.app.features.watching.sync.ProgressSyncRecord import com.nuvio.app.features.watching.sync.ProgressDeltaEvent import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotEquals import kotlin.test.assertTrue class WatchProgressIdentityTest { + @Test + fun `provider hidden content changes progress ui state without changing entries`() { + val entry = entry( + parentMetaId = "show", + videoId = "show:1:2", + progressKey = "show-episode", + lastUpdatedEpochMs = 10L, + ) + val visible = projectWatchProgressUiState( + source = WatchProgressSource.SIMKL, + entries = listOf(entry), + providerSnapshot = TrackingProgressSnapshot(), + hasLoadedNuvioRemoteProgress = false, + ) + val hidden = projectWatchProgressUiState( + source = WatchProgressSource.SIMKL, + entries = listOf(entry), + providerSnapshot = TrackingProgressSnapshot(hiddenContentIds = setOf("show")), + hasLoadedNuvioRemoteProgress = false, + ) + + assertEquals(visible.entries, hidden.entries) + assertNotEquals(visible, hidden) + } @Test fun `provider change during metadata batch schedules one follow up`() { From 085b136e5af075f56062228c10d35ae49342b091 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:18:25 +0530 Subject: [PATCH 53/59] fix(simkl): hide on-hold items from continue watching --- .../simkl/SimklApplicationAdapters.kt | 5 ++- .../simkl/SimklPlaybackReconciliation.kt | 17 ++++---- .../simkl/SimklPlaybackReconciliationTest.kt | 42 +++++++++++++++++-- 3 files changed, 51 insertions(+), 13 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt index 11f44e84..e3c8e438 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt @@ -197,7 +197,8 @@ object SimklTrackingProgressProvider : TrackingProgressProvider { val state = SimklProgressRepository.uiState.value return TrackingProgressSnapshot( entries = state.entries, - hiddenContentIds = SimklSyncRepository.state.value.snapshot.droppedContentIds(), + hiddenContentIds = SimklSyncRepository.state.value.snapshot + .hiddenFromContinueWatchingContentIds(), hasLoadedRemoteProgress = state.hasLoadedRemoteProgress, errorMessage = state.errorMessage, ) @@ -207,7 +208,7 @@ object SimklTrackingProgressProvider : TrackingProgressProvider { SimklProgressRepository.removeProgress(entries) override fun isHiddenFromProgress(contentId: String): Boolean = - SimklSyncRepository.state.value.snapshot.isDroppedContent(contentId) + SimklSyncRepository.state.value.snapshot.isHiddenFromContinueWatching(contentId) } private const val SIMKL_PLAYBACK_PROGRESS_KEY_PREFIX = "simkl-playback:" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliation.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliation.kt index baebb3c2..9caf01df 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliation.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliation.kt @@ -15,18 +15,21 @@ internal fun SimklSyncSnapshot.reconcileWatchedPlayback(): SimklSyncSnapshot { return if (retainedPlayback.size == playback.size) this else copy(playback = retainedPlayback) } -internal fun SimklSyncSnapshot.isDroppedContent(contentId: String): Boolean = +internal fun SimklSyncSnapshot.isHiddenFromContinueWatching(contentId: String): Boolean = entries.any { entry -> - entry.status == SimklListStatus.DROPPED && + entry.status.hidesContinueWatching() && entry.matchesContent(contentId = contentId, trackingProviderItemId = null) } -internal fun SimklSyncSnapshot.droppedContentIds(): Set = +internal fun SimklSyncSnapshot.hiddenFromContinueWatchingContentIds(): Set = entries.asSequence() - .filter { entry -> entry.status == SimklListStatus.DROPPED } + .filter { entry -> entry.status.hidesContinueWatching() } .mapNotNull { entry -> entry.media?.canonicalContentId() } .toSet() +internal fun SimklListStatus?.hidesContinueWatching(): Boolean = + this == SimklListStatus.ON_HOLD || this == SimklListStatus.DROPPED + private fun WatchedItem.supersedes(progress: WatchProgressEntry): Boolean { if (!type.equals(progress.contentType, ignoreCase = true)) return false if (season != progress.seasonNumber || episode != progress.episodeNumber) return false @@ -46,9 +49,9 @@ private fun SimklLibraryEntry.hidesPlayback(progress: WatchProgressEntry): Boole ) { return false } - return when (status) { - SimklListStatus.DROPPED -> true - SimklListStatus.COMPLETED -> + return when { + status.hidesContinueWatching() -> true + status == SimklListStatus.COMPLETED -> parseSimklUtcEpochMs(lastWatchedAt)?.let { completedAt -> completedAt >= progress.lastUpdatedEpochMs } == true diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliationTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliationTest.kt index 1253b1fb..ec31e628 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliationTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklPlaybackReconciliationTest.kt @@ -254,8 +254,39 @@ class SimklPlaybackReconciliationTest { ) assertTrue(snapshot.reconcileWatchedPlayback().playback.isEmpty()) - assertTrue(snapshot.isDroppedContent("tt4574334")) - assertEquals(setOf("tt4574334"), snapshot.droppedContentIds()) + assertTrue(snapshot.isHiddenFromContinueWatching("tt4574334")) + assertEquals( + setOf("tt4574334"), + snapshot.hiddenFromContinueWatchingContentIds(), + ) + } + + @Test + fun `on hold series discards playback using provider identity`() { + val snapshot = SimklSyncSnapshot( + entries = listOf( + SimklLibraryEntry( + mediaType = SimklMediaType.SHOWS, + status = SimklListStatus.ON_HOLD, + show = media(39687, imdb = "tt4574334"), + ), + ), + playback = listOf( + episodePlayback( + playbackMedia = media(39687, tvdb = "305288"), + season = 1, + episode = 5, + pausedAt = "2024-04-30T22:14:00Z", + ), + ), + ) + + assertTrue(snapshot.reconcileWatchedPlayback().playback.isEmpty()) + assertTrue(snapshot.isHiddenFromContinueWatching("tt4574334")) + assertEquals( + setOf("tt4574334"), + snapshot.hiddenFromContinueWatchingContentIds(), + ) } @Test @@ -279,8 +310,11 @@ class SimklPlaybackReconciliationTest { ) assertEquals(snapshot.playback, snapshot.reconcileWatchedPlayback().playback) - assertFalse(snapshot.isDroppedContent("tt4574334")) - assertEquals(setOf("tt1111111"), snapshot.droppedContentIds()) + assertFalse(snapshot.isHiddenFromContinueWatching("tt4574334")) + assertEquals( + setOf("tt1111111"), + snapshot.hiddenFromContinueWatchingContentIds(), + ) } @Test From c85831df26feb9d702685c222d11bcdb35835b91 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:35:43 +0530 Subject: [PATCH 54/59] feat(simkl): improve sync guidance navigation --- .../src/commonMain/composeResources/values/strings.xml | 1 + .../com/nuvio/app/features/settings/SimklSyncInfoDialog.kt | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 93f36faa..101346fa 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1163,6 +1163,7 @@ Nuvio automatically checks Simkl at most once every %1$d minutes. Changes made through another app or the Simkl website may take that long to appear. Each refresh first checks whether Simkl reports any changes and downloads only the updates. This follows Simkl’s API rules, reduces unnecessary data use, and helps keep the service stable. Changes made in Nuvio are sent to Simkl immediately. Use Sync now whenever you want Nuvio to check for remote changes sooner. + Shows placed in Simkl’s On Hold or Dropped lists are hidden from Continue Watching. They remain hidden for as long as they stay in either list. Read the Simkl sync guide Simkl authorization was revoked. Connect again. Simkl diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SimklSyncInfoDialog.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SimklSyncInfoDialog.kt index f5b449df..cda1c547 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SimklSyncInfoDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SimklSyncInfoDialog.kt @@ -28,6 +28,7 @@ import nuvio.composeapp.generated.resources.action_close import nuvio.composeapp.generated.resources.settings_simkl_sync_info_activity import nuvio.composeapp.generated.resources.settings_simkl_sync_info_description import nuvio.composeapp.generated.resources.settings_simkl_sync_info_docs +import nuvio.composeapp.generated.resources.settings_simkl_sync_info_library_statuses import nuvio.composeapp.generated.resources.settings_simkl_sync_info_manual import nuvio.composeapp.generated.resources.settings_simkl_sync_info_title import nuvio.composeapp.generated.resources.settings_trakt_failed_open_browser @@ -73,6 +74,11 @@ internal fun SimklSyncInfoDialog(onDismiss: () -> Unit) { style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + Text( + text = stringResource(Res.string.settings_simkl_sync_info_library_statuses), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) if (browserError) { Text( text = stringResource(Res.string.settings_trakt_failed_open_browser), From d8ffd18c4ddc4b5f70fb2f03d8bbb6c84e74abde Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:34:52 +0530 Subject: [PATCH 55/59] fix(simkl): pace requests after completion --- .../app/features/simkl/SimklApiClient.kt | 50 +++++++++++++------ .../app/features/simkl/SimklApiClientTest.kt | 33 ++++++++++-- 2 files changed, 65 insertions(+), 18 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt index b51d361b..15a48b63 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt @@ -95,17 +95,18 @@ internal class SimklApiClient( } var syncWriteLockRetried = false for (attempt in 0 until maxAttempts) { - awaitRateLimit(request.method) val response = try { - engine.execute( - method = request.method.name, - url = buildSimklApiUrl(request.path, request.query), - headers = simklRequestHeaders( - accessToken = token, - contentTypeJson = request.method == SimklHttpMethod.POST, - ), - body = request.body, - ) + executeRateLimited(request.method) { + engine.execute( + method = request.method.name, + url = buildSimklApiUrl(request.path, request.query), + headers = simklRequestHeaders( + accessToken = token, + contentTypeJson = request.method == SimklHttpMethod.POST, + ), + body = request.body, + ) + } } catch (error: CancellationException) { throw error } catch (error: Throwable) { @@ -165,17 +166,36 @@ internal class SimklApiClient( error("Simkl request loop completed without a response") } + private suspend fun executeRateLimited( + method: SimklHttpMethod, + block: suspend () -> T, + ): T { + awaitRateLimit(method) + return try { + block() + } finally { + recordRateLimitCompletion(method) + } + } + private suspend fun awaitRateLimit(method: SimklHttpMethod) { val now = nowEpochMs() val scheduledAt = when (method) { - SimklHttpMethod.GET -> max(now, nextGetAtEpochMs) - SimklHttpMethod.POST, SimklHttpMethod.DELETE -> max(now, nextPostAtEpochMs) + SimklHttpMethod.GET -> nextGetAtEpochMs + SimklHttpMethod.POST, SimklHttpMethod.DELETE -> nextPostAtEpochMs } if (scheduledAt > now) sleep(scheduledAt - now) - val requestAt = max(scheduledAt, nowEpochMs()) + } + + private fun recordRateLimitCompletion(method: SimklHttpMethod) { + val completedAt = nowEpochMs() when (method) { - SimklHttpMethod.GET -> nextGetAtEpochMs = requestAt + GET_INTERVAL_MS - SimklHttpMethod.POST, SimklHttpMethod.DELETE -> nextPostAtEpochMs = requestAt + POST_INTERVAL_MS + SimklHttpMethod.GET -> { + nextGetAtEpochMs = max(nextGetAtEpochMs, completedAt + GET_INTERVAL_MS) + } + SimklHttpMethod.POST, SimklHttpMethod.DELETE -> { + nextPostAtEpochMs = max(nextPostAtEpochMs, completedAt + POST_INTERVAL_MS) + } } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt index b51b0c28..839b6141 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt @@ -173,6 +173,26 @@ class SimklApiClientTest { assertEquals(listOf(0L, 100L, 100L, 1_100L), engine.requests.map { it.atEpochMs }) } + @Test + fun `post cooldown starts after the previous response completes`() = runBlocking { + val engine = RecordingEngine(response(200), response(200)) + val harness = TestHarness(engine, responseDurationMs = 400L) + + harness.client.execute( + SimklApiRequest( + method = SimklHttpMethod.POST, + path = "/oauth/token", + body = "{}", + requiresAuthentication = false, + retryPolicy = SimklRetryPolicy.NEVER, + ), + ) + harness.client.execute(SimklApiRequest(SimklHttpMethod.POST, "/users/settings")) + + assertEquals(listOf(1_000L), harness.sleeps) + assertEquals(listOf(0L, 1_400L), engine.requests.map { it.atEpochMs }) + } + @Test fun `transient responses retry sequentially and deterministic errors do not`() = runBlocking { val transientEngine = RecordingEngine(response(503), response(502), response(200)) @@ -290,12 +310,18 @@ class SimklApiClientTest { assertFalse(harness.wasUnauthorized) } - private class TestHarness(engine: RecordingEngine) { + private class TestHarness( + engine: RecordingEngine, + responseDurationMs: Long = 0L, + ) { var now = 0L val sleeps = mutableListOf() var wasUnauthorized = false val client = SimklApiClient( - engine = engine.also { recording -> recording.now = { now } }, + engine = engine.also { recording -> + recording.now = { now } + recording.onResponse = { now += responseDurationMs } + }, accessToken = { "token" }, onUnauthorized = { wasUnauthorized = true }, nowEpochMs = { now }, @@ -311,6 +337,7 @@ class SimklApiClientTest { private val queuedResponses = responses.toMutableList() val requests = mutableListOf() var now: () -> Long = { 0L } + var onResponse: () -> Unit = {} override suspend fun execute( method: String, @@ -319,7 +346,7 @@ class SimklApiClientTest { body: String, ): RawHttpResponse { requests += RecordedRequest(method, url, headers, body, now()) - return queuedResponses.removeAt(0) + return queuedResponses.removeAt(0).also { onResponse() } } } From 44fc3fa62f4601a6a0824561a495133c57562d75 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:04:33 +0530 Subject: [PATCH 56/59] fix(tracking): close early scrobble sessions --- .../PlayerScreenRuntimePlaybackActions.kt | 10 +++- .../player/PlayerScreenRuntimeStateTest.kt | 48 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt index 33bcf36f..6939aba1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt @@ -187,7 +187,10 @@ private fun PlayerScreenRuntime.emitTrackingScrobbleTerminal( internal fun PlayerScreenRuntime.emitStopScrobbleForCurrentProgress() { val progressPercent = currentPlaybackProgressPercent() - if (progressPercent >= 1f && progressPercent < 80f) { + if (!shouldSendStopScrobble(hasRequestedScrobbleStartForCurrentItem, progressPercent)) { + return + } + if (progressPercent < 80f) { emitTrackingScrobbleStop(progressPercent) return } @@ -198,6 +201,11 @@ internal fun PlayerScreenRuntime.emitStopScrobbleForCurrentProgress() { } } +internal fun shouldSendStopScrobble( + hasActiveScrobble: Boolean, + progressPercent: Float, +): Boolean = hasActiveScrobble || progressPercent >= 80f + internal fun shouldUpdateTrackingScrobbleAfterSeek( hasActiveScrobble: Boolean, progressPercent: Float, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeStateTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeStateTest.kt index 608c4868..0b8f720b 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeStateTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeStateTest.kt @@ -45,6 +45,54 @@ class PlayerScreenRuntimeStateTest { ) } + @Test + fun stopScrobble_closesActiveSessionBelowOnePercent() { + assertTrue( + shouldSendStopScrobble( + hasActiveScrobble = true, + progressPercent = 0f, + ), + ) + assertTrue( + shouldSendStopScrobble( + hasActiveScrobble = true, + progressPercent = 0.5f, + ), + ) + } + + @Test + fun stopScrobble_skipsEarlyProgressWithoutActiveSession() { + assertFalse( + shouldSendStopScrobble( + hasActiveScrobble = false, + progressPercent = 0.5f, + ), + ) + assertFalse( + shouldSendStopScrobble( + hasActiveScrobble = false, + progressPercent = 79.99f, + ), + ) + } + + @Test + fun stopScrobble_allowsCompletionWithoutActiveSession() { + assertTrue( + shouldSendStopScrobble( + hasActiveScrobble = false, + progressPercent = 80f, + ), + ) + assertTrue( + shouldSendStopScrobble( + hasActiveScrobble = false, + progressPercent = 100f, + ), + ) + } + private fun testPlayerScreenArgs() = PlayerScreenArgs( profileId = 1, title = "Title", From f2bf839e29a195870ab3d9169a891eb238a013f8 Mon Sep 17 00:00:00 2001 From: skoruppa Date: Sun, 26 Jul 2026 02:57:39 +0200 Subject: [PATCH 57/59] =?UTF-8?q?feat:=20Simkl=20anime=20tracking=20?= =?UTF-8?q?=E2=80=94=20per-season=20MAL/Kitsu=20ID=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Write path: - resolveAnimeEpisodeForSimkl() overrides IDs from video ID (Path B) - Only the anime-specific ID from videoId is sent (no shared IDs) - videoId flows through WatchedItem → push/delete → Simkl API Read path: - animeAlternateWatchedKeys() emits extra watched keys under alternate IDs - Observer on Simkl snapshot keeps watchedKeys reactive - SimklAnimeWatchedFallback handles franchise-parent content IDs - Optimistic removals prevent stale fallback reads Bootstrap: - full_anime_seasons + episode_tvdb_id + language=en for both bootstrap and changes Library: - Anime entries use type 'anime' and supportedContentTypes includes 'anime' Infrastructure: - WatchedItem.videoId field for episode video ID propagation - WatchedSyncAdapter.observeExtraWatchedKeys() for reactive extra keys - WatchedRepository observes provider extra keys and re-publishes --- .../simkl/SimklAnimeWatchedFallback.kt | 37 ++ .../simkl/SimklApplicationAdapters.kt | 33 +- .../features/simkl/SimklLibraryProjection.kt | 17 +- .../features/simkl/SimklMutationRepository.kt | 11 +- .../app/features/simkl/SimklProjections.kt | 165 ++++++ .../app/features/simkl/SimklSyncRemote.kt | 5 +- .../features/watched/WatchedEpisodeActions.kt | 1 + .../app/features/watched/WatchedModels.kt | 1 + .../app/features/watched/WatchedRepository.kt | 69 ++- .../watching/application/WatchingState.kt | 19 +- .../watching/sync/WatchedSyncAdapter.kt | 16 + .../simkl/SimklAnimeWatchedResolutionTest.kt | 556 ++++++++++++++++++ 12 files changed, 915 insertions(+), 15 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAnimeWatchedFallback.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklAnimeWatchedResolutionTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAnimeWatchedFallback.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAnimeWatchedFallback.kt new file mode 100644 index 00000000..d9d52e70 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAnimeWatchedFallback.kt @@ -0,0 +1,37 @@ +package com.nuvio.app.features.simkl + +/** + * Provides a fallback watched check for anime franchise-parent content IDs. + * + * When meta.id is a franchise parent (e.g. "mal:49233") that doesn't exist in + * Simkl's snapshot, episodes can't be resolved through watchedKeys alone. + * This fallback parses the video ID (e.g. "mal:32615:1") and checks the + * Simkl snapshot directly. + * + * Optimistic removals are tracked — after unmark, the videoId is blacklisted + * until the snapshot refreshes and confirms the removal. + */ +object SimklAnimeWatchedFallback { + private val optimisticallyRemoved = mutableSetOf() + + fun isWatched(videoId: String, episode: Int): Boolean { + if (videoId in optimisticallyRemoved) return false + return isWatchedByAnimeVideoId( + snapshot = SimklSyncRepository.state.value.snapshot, + videoId = videoId, + episode = episode, + ) + } + + fun markOptimisticallyRemoved(videoId: String) { + optimisticallyRemoved += videoId + } + + /** + * Called when snapshot refreshes — clear optimistic removals since + * the snapshot now reflects the true state. + */ + fun clearOptimisticRemovals() { + optimisticallyRemoved.clear() + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt index e3c8e438..7de75152 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -56,6 +57,23 @@ object SimklWatchedSyncAdapter : TrackingWatchedProvider { return projection.fullyWatchedSeriesKeys } + override suspend fun pullExtraWatchedKeys(profileId: Int): Set { + if (profileId != ProfileRepository.activeProfileId) return emptySet() + SimklSyncRepository.refresh( + intent = TrackingRefreshIntent.AUTOMATIC, + origin = SimklRefreshOrigin.WATCHED_ITEMS, + ) + return SimklSyncRepository.state.value.snapshot.animeAlternateWatchedKeys() + } + + override fun observeExtraWatchedKeys(profileId: Int): kotlinx.coroutines.flow.Flow> = + SimklSyncRepository.state + .map { state -> + SimklAnimeWatchedFallback.clearOptimisticRemovals() + state.snapshot.animeAlternateWatchedKeys() + } + .distinctUntilChanged() + override suspend fun push(profileId: Int, items: Collection) { if (profileId != ProfileRepository.activeProfileId || items.isEmpty()) return SimklSyncRepository.ensureLoaded() @@ -69,6 +87,7 @@ object SimklWatchedSyncAdapter : TrackingWatchedProvider { releaseInfo = item.releaseInfo, season = item.season, episode = item.episode, + videoId = item.videoId, ), watchedAtEpochMs = item.markedAtEpochMs, ) @@ -81,6 +100,8 @@ object SimklWatchedSyncAdapter : TrackingWatchedProvider { override suspend fun delete(profileId: Int, items: Collection) { if (profileId != ProfileRepository.activeProfileId || items.isEmpty()) return + // Optimistically mark video IDs as removed so fallback won't show them as watched + items.forEach { item -> item.videoId?.let(SimklAnimeWatchedFallback::markOptimisticallyRemoved) } SimklSyncRepository.ensureLoaded() val snapshot = SimklSyncRepository.state.value.snapshot val media = items.map { item -> @@ -91,7 +112,11 @@ object SimklWatchedSyncAdapter : TrackingWatchedProvider { releaseInfo = item.releaseInfo, season = item.season, episode = item.episode, - ) + videoId = item.videoId, + ).let { ref -> + val enriched = snapshot.enrichMediaReference(ref) + enriched.resolveAnimeEpisodeForSimkl() + } } val result = SimklMutationRepository.removeFromHistory(profileId = profileId, items = media) check(result.isComplete) { @@ -209,6 +234,12 @@ object SimklTrackingProgressProvider : TrackingProgressProvider { override fun isHiddenFromProgress(contentId: String): Boolean = SimklSyncRepository.state.value.snapshot.isHiddenFromContinueWatching(contentId) + + override fun normalizeParentContentId(parentContentId: String, videoId: String?): String { + val snapshot = SimklSyncRepository.state.value.snapshot + val resolvedId = snapshot.resolveCanonicalContentId(parentContentId) + return resolvedId ?: parentContentId + } } private const val SIMKL_PLAYBACK_PROGRESS_KEY_PREFIX = "simkl-playback:" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt index ac50f1c2..218bbdfa 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt @@ -28,28 +28,28 @@ internal val simklLibraryStatusDefinitions = listOf( key = "simkl:status:watching", title = "Watching", trackingStatus = TrackingListStatus.WATCHING, - supportedContentTypes = setOf("series"), + supportedContentTypes = setOf("series", "anime"), ), SimklLibraryStatusDefinition( status = SimklListStatus.PLAN_TO_WATCH, key = "simkl:status:plantowatch", title = "Plan to Watch", trackingStatus = TrackingListStatus.PLAN_TO_WATCH, - supportedContentTypes = setOf("movie", "series"), + supportedContentTypes = setOf("movie", "series", "anime"), ), SimklLibraryStatusDefinition( status = SimklListStatus.ON_HOLD, key = "simkl:status:hold", title = "On Hold", trackingStatus = TrackingListStatus.ON_HOLD, - supportedContentTypes = setOf("series"), + supportedContentTypes = setOf("series", "anime"), ), SimklLibraryStatusDefinition( status = SimklListStatus.COMPLETED, key = "simkl:status:completed", title = "Completed", trackingStatus = TrackingListStatus.COMPLETED, - supportedContentTypes = setOf("movie", "series"), + supportedContentTypes = setOf("movie", "series", "anime"), isMembershipDestination = false, ), SimklLibraryStatusDefinition( @@ -57,7 +57,7 @@ internal val simklLibraryStatusDefinitions = listOf( key = "simkl:status:dropped", title = "Dropped", trackingStatus = TrackingListStatus.DROPPED, - supportedContentTypes = setOf("movie", "series"), + supportedContentTypes = setOf("movie", "series", "anime"), ), ) @@ -100,9 +100,14 @@ private fun SimklLibraryEntry.toLibraryItem( val media = media ?: return null val contentId = media.canonicalContentId() ?: return null val simklId = media.ids.simklIdValue()?.toLongOrNull() + val entryType = when (mediaType) { + SimklMediaType.MOVIES -> "movie" + SimklMediaType.ANIME -> "anime" + SimklMediaType.SHOWS -> "series" + } return LibraryItem( id = contentId, - type = if (mediaType == SimklMediaType.MOVIES) "movie" else "series", + type = entryType, name = media.title?.takeIf(String::isNotBlank) ?: contentId, poster = simklPosterUrl(media.poster), releaseInfo = media.year?.toString(), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt index 79041d08..6360a1ed 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt @@ -164,7 +164,13 @@ object SimklMutationRepository : TrackingListWriter, TrackingHistoryWriter, Trac items: Collection, ): TrackingMutationResult { if (!isActiveProfile(profileId)) return TrackingMutationResult(attemptedCount = 0) - return service.addToHistory(items) + SimklSyncRepository.ensureLoaded() + val snapshot = SimklSyncRepository.state.value.snapshot + val resolved = items.map { item -> + val enriched = snapshot.enrichMediaReference(item.media) + item.copy(media = enriched.resolveAnimeEpisodeForSimkl()) + } + return service.addToHistory(resolved) } override suspend fun removeFromHistory( @@ -182,10 +188,11 @@ object SimklMutationRepository : TrackingListWriter, TrackingHistoryWriter, Trac ) { if (!isActiveProfile(profileId)) return SimklSyncRepository.ensureLoaded() + val enriched = SimklSyncRepository.state.value.snapshot.enrichMediaReference(event.media) service.scrobble( action = action, event = event.copy( - media = SimklSyncRepository.state.value.snapshot.enrichMediaReference(event.media), + media = enriched.resolveAnimeEpisodeForSimkl(), ), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt index f699e6fe..db7e9411 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt @@ -1,5 +1,6 @@ package com.nuvio.app.features.simkl +import com.nuvio.app.features.tracking.TrackingCatalogReference import com.nuvio.app.features.tracking.TrackingEpisode import com.nuvio.app.features.tracking.TrackingExternalIds import com.nuvio.app.features.tracking.TrackingMediaKind @@ -103,6 +104,43 @@ internal fun SimklSyncSnapshot.toSimklWatchedProjection(): SimklWatchedProjectio ) } +/** + * Builds a set of extra watched keys under all alternate content IDs for anime entries. + * These are NOT added as items (to avoid CW duplicates), but are used to augment + * the watchedKeys set so the UI can resolve watched status regardless of which + * content ID the addon uses. + */ +internal fun SimklSyncSnapshot.animeAlternateWatchedKeys(): Set { + val extraKeys = linkedSetOf() + entries.forEach { entry -> + if (entry.mediaType != SimklMediaType.ANIME) return@forEach + val media = entry.media ?: return@forEach + val contentId = media.canonicalContentId() ?: return@forEach + val contentType = "series" + val alternateIds = media.alternateContentIds() - contentId + + if (alternateIds.isEmpty()) return@forEach + + entry.seasons.forEach { season -> + season.episodes.forEach episodeLoop@{ episode -> + val seasonNumber = episode.tvdb?.season ?: season.number ?: return@episodeLoop + val episodeNumber = episode.tvdb?.episode ?: episode.number ?: return@episodeLoop + if (episode.watchedAt == null) return@episodeLoop + alternateIds.forEach { altId -> + extraKeys += watchedItemKey(contentType, altId, seasonNumber, episodeNumber) + } + } + } + + if (entry.status == SimklListStatus.COMPLETED) { + alternateIds.forEach { altId -> + extraKeys += watchedItemKey(contentType, altId) + } + } + } + return extraKeys +} + internal fun SimklSyncSnapshot.toSimklProgressEntries(): List = playback .mapNotNull(SimklPlaybackSession::toWatchProgressEntry) @@ -118,6 +156,7 @@ internal fun SimklSyncSnapshot.mediaReference( season: Int? = null, episode: Int? = null, episodeTitle: String? = null, + videoId: String? = null, ): TrackingMediaReference { val entry = entries.firstOrNull { candidate -> candidate.matchesContentId(contentId) } val media = entry?.media @@ -137,6 +176,11 @@ internal fun SimklSyncSnapshot.mediaReference( episode = episode?.let { number -> TrackingEpisode(season = season, number = number, title = episodeTitle) }, + catalog = TrackingCatalogReference( + contentId = contentId, + contentType = contentType, + videoId = videoId?.trim()?.takeIf(String::isNotBlank), + ), ) } @@ -181,6 +225,22 @@ internal fun SimklMedia.canonicalContentId(): String? = when { else -> null } +/** + * Returns all content IDs (IMDB, MAL, AniDB, AniList, Kitsu, etc.) that this media entry + * is known under. Used to emit watched items under all possible IDs for anime entries so + * that the UI can find them regardless of which content ID the addon uses. + */ +private fun SimklMedia.alternateContentIds(): Set = buildSet { + ids.idValue("imdb")?.takeIf(String::isNotBlank)?.let(::add) + ids.idValue("tmdb")?.takeIf(String::isNotBlank)?.let { add("tmdb:$it") } + ids.idValue("tvdb")?.takeIf(String::isNotBlank)?.let { add("tvdb:$it") } + ids.idValue("mal")?.takeIf(String::isNotBlank)?.let { add("mal:$it") } + ids.idValue("anidb")?.takeIf(String::isNotBlank)?.let { add("anidb:$it") } + ids.idValue("anilist")?.takeIf(String::isNotBlank)?.let { add("anilist:$it") } + ids.idValue("kitsu")?.takeIf(String::isNotBlank)?.let { add("kitsu:$it") } + ids.simklIdValue()?.takeIf(String::isNotBlank)?.let { add("simkl:$it") } +} + internal fun simklPosterUrl(path: String?): String? = path ?.trim() @@ -308,6 +368,111 @@ private fun daysInMonths(year: Int): IntArray = if (year.isLeapYear()) { intArrayOf(31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) } +/** + * For anime with per-season MAL/Kitsu/AniDB/AniList video IDs, resolves both the + * anime-specific IDs and the episode number to match Simkl's anime-native format + * (Path B: flat episode numbering per MAL/Kitsu entry, no season). + * + * Example: video ID "mal:42203:7" means episode 7 of mal:42203. Even if the UI shows + * this as "Season 2 Episode 20" of the franchise, Simkl needs: + * - ids with mal=42203 (not the franchise parent mal) + * - episode number=7 (not 20) + * - no season (flat/absolute numbering) + * + * Returns the reference unchanged if: + * - Not anime + * - No catalog videoId + * - VideoId doesn't have an anime-tracker prefix with episode number + */ +internal fun TrackingMediaReference.resolveAnimeEpisodeForSimkl(): TrackingMediaReference { + if (kind != TrackingMediaKind.ANIME) return this + val videoId = catalog?.videoId ?: return this + val parsed = parseSimklAnimeVideoId(videoId) ?: return this + val videoEpisodeNumber = parsed.episodeNumber ?: return this + + // Override IDs: use ONLY the anime-specific ID from videoId. + // Clear all other IDs to prevent Simkl from matching a wrong entry + // (e.g. shared anidb/anilist IDs from the enriched parent entry). + val videoIds = parseTrackingExternalIds("${parsed.prefix}:${parsed.id}") + val overriddenIds = TrackingExternalIds( + imdb = null, + tmdb = null, + tvdb = null, + trakt = null, + simkl = null, + mal = videoIds.mal, + anidb = videoIds.anidb, + anilist = videoIds.anilist, + kitsu = videoIds.kitsu, + ) + + // Override episode: use absolute number from videoId, drop season + return copy( + ids = overriddenIds, + episode = episode?.copy(season = null, number = videoEpisodeNumber) + ?: TrackingEpisode(season = null, number = videoEpisodeNumber), + ) +} + +/** + * Resolve a contentId to its canonical form by finding a matching Simkl entry. + * e.g. "mal:123" → finds entry with mal=123 → returns "tt2560140" (its IMDB/canonical ID) + */ +internal fun SimklSyncSnapshot.resolveCanonicalContentId(contentId: String): String? { + val entry = entries.firstOrNull { it.matchesContentId(contentId) } + return entry?.media?.canonicalContentId() +} + +/** + * Check if an episode is watched by resolving through the video ID. + * Parses the anime-specific ID from videoId (e.g. "mal:123:5" → mal=123, ep=5), + * finds the Simkl entry, and checks if that episode number has watched_at. + */ +internal fun isWatchedByAnimeVideoId( + snapshot: SimklSyncSnapshot, + videoId: String, + episode: Int, +): Boolean { + val parsed = parseSimklAnimeVideoId(videoId) ?: return false + val parsedIds = parseTrackingExternalIds("${parsed.prefix}:${parsed.id}") + val entry = snapshot.entries.firstOrNull { entry -> + entry.mediaType == SimklMediaType.ANIME && + entry.media?.toTrackingExternalIds()?.sharesAnimeIdentityWith(parsedIds) == true + } ?: return false + + val targetEpisodeNumber = parsed.episodeNumber ?: episode + return entry.seasons.any { season -> + season.episodes.any { ep -> + ep.number == targetEpisodeNumber && ep.watchedAt != null + } + } +} + +private fun TrackingExternalIds.sharesAnimeIdentityWith(other: TrackingExternalIds): Boolean = + (mal != null && mal == other.mal) || + (anidb != null && anidb == other.anidb) || + (anilist != null && anilist == other.anilist) || + (kitsu != null && kitsu == other.kitsu) + +private val SIMKL_ANIME_VIDEO_ID_PREFIXES = setOf("mal", "anidb", "anilist", "kitsu") + +private data class SimklAnimeVideoIdParts( + val prefix: String, + val id: Long, + val episodeNumber: Int?, +) + +private fun parseSimklAnimeVideoId(videoId: String): SimklAnimeVideoIdParts? { + val trimmed = videoId.trim() + if (trimmed.isBlank()) return null + val prefix = trimmed.substringBefore(':').lowercase() + if (prefix !in SIMKL_ANIME_VIDEO_ID_PREFIXES) return null + val afterPrefix = trimmed.substringAfter(':', "") + val idValue = afterPrefix.substringBefore(':').toLongOrNull() ?: return null + val episodePart = afterPrefix.substringAfter(':', "").substringBefore(':') + return SimklAnimeVideoIdParts(prefix, idValue, episodePart.toIntOrNull()) +} + private val SIMKL_UTC_PATTERN = Regex( "^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})(?:\\.(\\d{1,9}))?Z$", RegexOption.IGNORE_CASE, 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 index f1bad9ca..229ccfee 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRemote.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRemote.kt @@ -51,9 +51,11 @@ internal class SimklApiSyncRemote( ?: "/sync/all-items" val query = when (request) { is SimklAllItemsRequest.Bootstrap -> mapOf( - "extended" to "full", + "extended" to "full_anime_seasons", "episode_watched_at" to "yes", + "episode_tvdb_id" to "yes", "include_all_episodes" to "yes", + "language" to "en", ) is SimklAllItemsRequest.Changes -> mapOf( "date_from" to request.dateFrom, @@ -61,6 +63,7 @@ internal class SimklApiSyncRemote( "episode_watched_at" to "yes", "episode_tvdb_id" to "yes", "include_all_episodes" to "yes", + "language" to "en", ) SimklAllItemsRequest.CurrentIds -> mapOf( "extended" to "simkl_ids_only", diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActions.kt index f634aa20..1bd1164b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActions.kt @@ -33,6 +33,7 @@ fun MetaDetails.toEpisodeWatchedItem( releaseInfo = releaseInfo, season = video.season, episode = video.episode, + videoId = video.id, markedAtEpochMs = markedAtEpochMs, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt index 4a27fd4e..f3031702 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt @@ -16,6 +16,7 @@ data class WatchedItem( val releaseInfo: String? = null, val season: Int? = null, val episode: Int? = null, + val videoId: String? = null, override val trackingProviderId: String? = null, override val trackingProviderItemId: String? = null, override val trackingSourceUrl: String? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt index 0f4a7ee1..63126664 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt @@ -26,6 +26,9 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString @@ -133,6 +136,7 @@ object WatchedRepository { private var providerItemsByKey: MutableMap> = mutableMapOf() private var nuvioFullyWatchedSeriesKeys: Set = emptySet() private var providerFullyWatchedSeriesKeys: MutableMap> = mutableMapOf() + private var providerExtraWatchedKeys: MutableMap> = mutableMapOf() private var nuvioHasLoaded: Boolean = false private var loadedProviders: MutableSet = mutableSetOf() private var nuvioHasLoadedRemote: Boolean = false @@ -142,6 +146,7 @@ object WatchedRepository { private var deltaCursorEventId: Long = 0L private var deltaInitialized: Boolean = false internal var syncAdapter: WatchedSyncAdapter = SupabaseWatchedSyncAdapter + private var extraKeysObserverJob: Job? = null fun ensureLoaded() { ensureTrackingProvidersRegistered() @@ -156,6 +161,7 @@ object WatchedRepository { ), ) } + startExtraKeysObserverIfNeeded() } fun onProfileChanged(profileId: Int) { @@ -171,6 +177,7 @@ object WatchedRepository { } } previousAccountJob.cancel() + extraKeysObserverJob = null hasLoaded = false currentProfileId = 1 profileGeneration += 1L @@ -180,6 +187,7 @@ object WatchedRepository { providerItemsByKey.clear() nuvioFullyWatchedSeriesKeys = emptySet() providerFullyWatchedSeriesKeys.clear() + providerExtraWatchedKeys.clear() nuvioHasLoaded = false loadedProviders.clear() nuvioHasLoadedRemote = false @@ -202,6 +210,7 @@ object WatchedRepository { providerItemsByKey.clear() nuvioFullyWatchedSeriesKeys = emptySet() providerFullyWatchedSeriesKeys.clear() + providerExtraWatchedKeys.clear() nuvioHasLoaded = true loadedProviders.clear() nuvioHasLoadedRemote = false @@ -253,6 +262,7 @@ object WatchedRepository { source.providerId?.let { providerId -> providerItemsByKey.getOrPut(providerId, ::mutableMapOf).clear() providerFullyWatchedSeriesKeys[providerId] = emptySet() + providerExtraWatchedKeys.remove(providerId) loadedProviders -= providerId providersLoadedFromRemote -= providerId } ?: run { @@ -260,11 +270,13 @@ object WatchedRepository { } activeSource = source sourceGeneration += 1L + stopExtraKeysObserver() log.i { "Watched source activated previous=$previousSource current=$source generation=$sourceGeneration " + "provider=${source.providerId?.storageId}" } publish() + startExtraKeysObserverIfNeeded() return source } @@ -460,7 +472,11 @@ object WatchedRepository { fullyWatchedSeriesKeys?.let { keys -> setFullyWatchedSeriesKeysForSource(operation.sourceOperation.source, keys) } + val extraWatchedKeys = adapter.pullExtraWatchedKeys(profileId) operation.sourceOperation.source.providerId?.let { providerId -> + if (extraWatchedKeys.isNotEmpty()) { + providerExtraWatchedKeys[providerId] = extraWatchedKeys + } loadedProviders += providerId providersLoadedFromRemote += providerId } ?: run { @@ -772,10 +788,25 @@ object WatchedRepository { val targetItems = itemsForSource(source) val removedItems = items.mapNotNull { watchedItem -> val key = watchedItemKey(watchedItem.type, watchedItem.id, watchedItem.season, watchedItem.episode) - targetItems.remove(key)?.also { + targetItems.remove(key)?.let { storeItem -> + // Preserve videoId from the original request (store items don't have it) + if (watchedItem.videoId != null && storeItem.videoId == null) { + storeItem.copy(videoId = watchedItem.videoId) + } else { + storeItem + } + }?.also { if (source.providerId == null) { nuvioDirtyWatchedKeys -= key } + // Optimistically remove from extra keys so publish() doesn't re-add it + source.providerId?.let { providerId -> + providerExtraWatchedKeys[providerId]?.let { extraKeys -> + if (key in extraKeys) { + providerExtraWatchedKeys[providerId] = extraKeys - key + } + } + } } } if (removedItems.isNotEmpty()) { @@ -784,6 +815,10 @@ object WatchedRepository { persistNuvio() } pushDeleteToServer(items = removedItems, source = source) + } else if (source.providerId != null) { + // Items not found in local store (e.g. anime resolved via snapshot fallback). + // Still push delete to provider so it can remove from remote. + pushDeleteToServer(items = items.toList(), source = source) } } @@ -941,6 +976,10 @@ object WatchedRepository { val watchedKeys = items.mapTo(linkedSetOf()) { watchedItemKey(it.type, it.id, it.season, it.episode) } + // Merge extra watched keys from providers (e.g. Simkl anime alternate IDs) + activeSource.providerId?.let { providerId -> + providerExtraWatchedKeys[providerId]?.let { extraKeys -> watchedKeys += extraKeys } + } val isLoaded = hasLoadedSource(activeSource) val hasLoadedRemoteItems = activeSource.providerId ?.let(providersLoadedFromRemote::contains) @@ -968,6 +1007,34 @@ object WatchedRepository { watchedItemKey(item.type, item.id, item.season, item.episode) } + /** + * Observes provider extra watched keys (e.g. Simkl anime alternate IDs). + * When the provider's snapshot changes (after mutations, syncs), recomputes + * extra keys and re-publishes so watchedKeys stays reactive and current. + */ + private fun startExtraKeysObserverIfNeeded() { + if (extraKeysObserverJob != null) return + val providerId = activeSource.providerId ?: return + val adapter = TrackingProviderRegistry.connectedWatchedProviders() + .firstOrNull { it.providerId == providerId } ?: return + extraKeysObserverJob = accountScopeSnapshot().launch { + adapter.observeExtraWatchedKeys(currentProfileId) + .distinctUntilChanged() + .collectLatest { extraKeys -> + val changed = providerExtraWatchedKeys[providerId] != extraKeys + if (changed) { + providerExtraWatchedKeys[providerId] = extraKeys + publish() + } + } + } + } + + private fun stopExtraKeysObserver() { + extraKeysObserverJob?.cancel() + extraKeysObserverJob = null + } + private fun persistNuvio() { WatchedStorage.savePayload( currentProfileId, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt index f098c65f..448afc7e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt @@ -30,14 +30,25 @@ object WatchingState { metaType: String, metaId: String, episode: MetaVideo, - ): Boolean = watchedKeys.contains( - watchedItemKey( + ): Boolean { + val key = watchedItemKey( type = metaType, id = metaId, season = episode.season, episode = episode.episode, - ), - ) + ) + if (watchedKeys.contains(key)) return true + + // Fallback for franchise-parent anime: meta.id (e.g. "mal:49233") may differ + // from the actual entry ID in Simkl. Check via video ID resolution in snapshot. + // Only for anime-style video IDs (mal:, kitsu:, etc.) — not IMDB/TVDB content. + val videoId = episode.id + val episodeNumber = episode.episode + if (episodeNumber != null) { + return com.nuvio.app.features.simkl.SimklAnimeWatchedFallback.isWatched(videoId, episodeNumber) + } + return false + } fun areEpisodesWatched( watchedKeys: Set, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/WatchedSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/WatchedSyncAdapter.kt index 7ad50c42..df5b8b4c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/WatchedSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/WatchedSyncAdapter.kt @@ -1,6 +1,8 @@ package com.nuvio.app.features.watching.sync import com.nuvio.app.features.watched.WatchedItem +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf data class WatchedDeltaEvent( val eventId: Long, @@ -21,6 +23,20 @@ interface WatchedSyncAdapter { suspend fun pullFullyWatchedSeriesKeys(profileId: Int): Set? = null + /** + * Returns extra watched keys that should be added to the watched keys set + * without creating additional watched items (to avoid duplicates in CW). + * Used by Simkl for anime alternate content IDs (MAL, AniDB, etc.). + */ + suspend fun pullExtraWatchedKeys(profileId: Int): Set = emptySet() + + /** + * Returns a Flow of extra watched keys that updates reactively when the + * provider's state changes (e.g. after mutations or syncs). + * Default implementation emits the result of pullExtraWatchedKeys once. + */ + fun observeExtraWatchedKeys(profileId: Int): Flow> = flowOf(emptySet()) + suspend fun getDeltaCursor(profileId: Int): Long? = null suspend fun pullDelta( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklAnimeWatchedResolutionTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklAnimeWatchedResolutionTest.kt new file mode 100644 index 00000000..f11f27fb --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklAnimeWatchedResolutionTest.kt @@ -0,0 +1,556 @@ +package com.nuvio.app.features.simkl + +import com.nuvio.app.features.tracking.TrackingCatalogReference +import com.nuvio.app.features.tracking.TrackingEpisode +import com.nuvio.app.features.tracking.TrackingExternalIds +import com.nuvio.app.features.tracking.TrackingMediaKind +import com.nuvio.app.features.tracking.TrackingMediaReference +import com.nuvio.app.features.watched.watchedItemKey +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Tests for anime watched status resolution across different content ID scenarios: + * - Direct MAL entry (single MAL = single season) + * - Multiple MAL entries sharing the same IMDB (different seasons of the same franchise) + * - Split season (one TVDB season mapped to multiple MAL entries) + * - Franchise parent content ID that doesn't exist in Simkl + * - resolveAnimeEpisodeForSimkl() write-path transformation + * - Alternate content ID emission for watched projection + */ +class SimklAnimeWatchedResolutionTest { + + // ────────────────────────────────────────────────────────────────────────── + // Scenario 1: Multiple MAL entries represent different seasons of the same IMDB. + // canonicalContentId() for both returns "tt2560140" (IMDB wins). + // Watched items end up under the same contentId with different tvdb seasons. + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `multiple MAL entries with same IMDB produce watched items for all seasons`() { + val snapshot = snapshotWithMultiSeasonAnime() + val projection = snapshot.toSimklWatchedProjection() + + val s1Items = projection.items.filter { it.id == "tt2560140" && it.season == 1 } + val s2Items = projection.items.filter { it.id == "tt2560140" && it.season == 2 } + + assertEquals(3, s1Items.size) + assertEquals(2, s2Items.size) + assertTrue(s1Items.any { it.episode == 1 }) + assertTrue(s1Items.any { it.episode == 3 }) + assertTrue(s2Items.any { it.episode == 1 }) + assertTrue(s2Items.any { it.episode == 2 }) + } + + @Test + fun `querying watched status via IMDB finds episodes from both MAL entries`() { + val snapshot = snapshotWithMultiSeasonAnime() + val items = snapshot.toSimklWatchedProjection().items + + assertTrue(items.any { it.id == "tt2560140" && it.season == 1 && it.episode == 1 }) + assertTrue(items.any { it.id == "tt2560140" && it.season == 2 && it.episode == 2 }) + assertFalse(items.any { it.id == "tt2560140" && it.season == 2 && it.episode == 99 }) + } + + // ────────────────────────────────────────────────────────────────────────── + // Scenario 2: MAL contentId resolves to matching entry. + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `MAL contentId resolves to matching entry via matchesContentId`() { + val snapshot = snapshotWithMultiSeasonAnime() + + val entry = snapshot.entries.firstOrNull { it.matchesContentId("mal:123") } + assertNotNull(entry) + assertEquals("tt2560140", entry.media?.canonicalContentId()) + } + + @Test + fun `resolveCanonicalContentId maps MAL ID to IMDB canonical`() { + val snapshot = snapshotWithMultiSeasonAnime() + + assertEquals("tt2560140", snapshot.resolveCanonicalContentId("mal:123")) + assertEquals("tt2560140", snapshot.resolveCanonicalContentId("mal:4372")) + } + + // ────────────────────────────────────────────────────────────────────────── + // Scenario 3: Alternate content IDs — anime alternate watched keys are + // produced separately (not as duplicate items) for isWatched resolution. + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `anime watched items are only emitted under canonical ID not duplicated`() { + val snapshot = snapshotWithMultiSeasonAnime() + val items = snapshot.toSimklWatchedProjection().items + + // Items exist only under canonical "tt2560140" (IMDB wins) + assertTrue(items.all { it.id == "tt2560140" }) + // No items under MAL alternate IDs + assertFalse(items.any { it.id == "mal:123" }) + assertFalse(items.any { it.id == "mal:4372" }) + } + + @Test + fun `animeAlternateWatchedKeys produces keys for alternate IDs`() { + val snapshot = snapshotWithMultiSeasonAnime() + val extraKeys = snapshot.animeAlternateWatchedKeys() + + // Should contain keys for "mal:123" episodes (alternate of canonical "tt2560140") + assertTrue(extraKeys.contains(watchedItemKey("series", "mal:123", 1, 1))) + assertTrue(extraKeys.contains(watchedItemKey("series", "mal:123", 1, 2))) + assertTrue(extraKeys.contains(watchedItemKey("series", "mal:123", 1, 3))) + + // Should contain keys for "mal:4372" episodes + assertTrue(extraKeys.contains(watchedItemKey("series", "mal:4372", 2, 1))) + assertTrue(extraKeys.contains(watchedItemKey("series", "mal:4372", 2, 2))) + + // Should contain simkl: alternate keys too + assertTrue(extraKeys.contains(watchedItemKey("series", "simkl:39687", 1, 1))) + assertTrue(extraKeys.contains(watchedItemKey("series", "simkl:39688", 2, 1))) + } + + @Test + fun `non-anime show entries do NOT produce alternate watched keys`() { + val show = SimklLibraryEntry( + mediaType = SimklMediaType.SHOWS, + status = SimklListStatus.WATCHING, + lastWatchedAt = "2023-11-14T23:00:00Z", + seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2023-11-14T23:00:00Z", SimklEpisodeMapping(1, 1)), + )), + ), + show = SimklMedia( + title = "Regular Show", + year = 2020, + ids = buildJsonObject { + put("simkl", 99999) + put("imdb", "tt9999999") + put("mal", 88888) + }, + ), + ) + val snapshot = SimklSyncSnapshot(entries = listOf(show)) + val extraKeys = snapshot.animeAlternateWatchedKeys() + + assertTrue(extraKeys.isEmpty()) + } + + // ────────────────────────────────────────────────────────────────────────── + // Scenario 4: Franchise parent content ID that doesn't exist in Simkl. + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `franchise parent not in snapshot cannot be resolved`() { + val snapshot = snapshotWithMultiSeasonAnime() + + assertNull(snapshot.resolveCanonicalContentId("mal:42423")) + } + + @Test + fun `anime videoId resolves watched episode via isWatchedByAnimeVideoId`() { + val snapshot = snapshotWithMultiSeasonAnime() + + // "mal:123:3" → entry with mal=123, episode 3 (watched) + assertTrue(isWatchedByAnimeVideoId(snapshot, "mal:123:3", episode = 3)) + + // "mal:4372:1" → entry with mal=4372, episode 1 (watched) + assertTrue(isWatchedByAnimeVideoId(snapshot, "mal:4372:1", episode = 1)) + + // "mal:4372:99" → episode 99 not watched + assertFalse(isWatchedByAnimeVideoId(snapshot, "mal:4372:99", episode = 99)) + + // Non-existent MAL → false + assertFalse(isWatchedByAnimeVideoId(snapshot, "mal:42423:5", episode = 5)) + } + + // ────────────────────────────────────────────────────────────────────────── + // Scenario 5: Split season — one TVDB season, two MAL entries. + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `split season entries produce watched items with correct tvdb mapping`() { + val snapshot = snapshotWithSplitSeason() + val items = snapshot.toSimklWatchedProjection().items + + // mal:11757 episodes map to TVDB S1E1-S1E14 + assertTrue(items.any { it.id == "tt2250192" && it.season == 1 && it.episode == 1 }) + assertTrue(items.any { it.id == "tt2250192" && it.season == 1 && it.episode == 14 }) + + // mal:11759 episodes map to TVDB S1E15-S1E19 (only 5 watched) + assertTrue(items.any { it.id == "tt2250192" && it.season == 1 && it.episode == 15 }) + assertTrue(items.any { it.id == "tt2250192" && it.season == 1 && it.episode == 19 }) + + // Episode 20+ not watched + assertFalse(items.any { it.id == "tt2250192" && it.season == 1 && it.episode == 20 }) + } + + @Test + fun `split season resolves watched via isWatchedByAnimeVideoId`() { + val snapshot = snapshotWithSplitSeason() + + // "mal:11757:14" → entry with mal=11757, episode 14 (watched) + assertTrue(isWatchedByAnimeVideoId(snapshot, "mal:11757:14", episode = 14)) + + // "mal:11759:1" → entry with mal=11759, episode 1 (watched) + assertTrue(isWatchedByAnimeVideoId(snapshot, "mal:11759:1", episode = 1)) + + // "mal:11759:6" → entry with mal=11759, episode 6 (NOT watched) + assertFalse(isWatchedByAnimeVideoId(snapshot, "mal:11759:6", episode = 6)) + } + + // ────────────────────────────────────────────────────────────────────────── + // Scenario 6: resolveAnimeEpisodeForSimkl — write path transformation + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `resolveAnimeEpisodeForSimkl overrides MAL ID and episode from videoId`() { + val reference = TrackingMediaReference( + kind = TrackingMediaKind.ANIME, + title = "Some Anime", + year = 2020, + ids = TrackingExternalIds(imdb = "tt2560140", mal = 31240, simkl = 39687), + episode = TrackingEpisode(season = 2, number = 20), + catalog = TrackingCatalogReference( + contentId = "mal:31240", + contentType = "series", + videoId = "mal:42203:7", + ), + ) + + val resolved = reference.resolveAnimeEpisodeForSimkl() + + assertEquals(42203L, resolved.ids.mal) + assertEquals("tt2560140", resolved.ids.imdb) + assertEquals(39687L, resolved.ids.simkl) + assertNull(resolved.episode?.season) + assertEquals(7, resolved.episode?.number) + } + + @Test + fun `resolveAnimeEpisodeForSimkl with kitsu videoId`() { + val reference = TrackingMediaReference( + kind = TrackingMediaKind.ANIME, + title = "Kitsu Anime", + ids = TrackingExternalIds(imdb = "tt5311514", kitsu = 99999), + episode = TrackingEpisode(season = 1, number = 5), + catalog = TrackingCatalogReference( + contentId = "kitsu:99999", + contentType = "series", + videoId = "kitsu:12268:3", + ), + ) + + val resolved = reference.resolveAnimeEpisodeForSimkl() + + assertEquals(12268L, resolved.ids.kitsu) + assertEquals("tt5311514", resolved.ids.imdb) + assertNull(resolved.episode?.season) + assertEquals(3, resolved.episode?.number) + } + + @Test + fun `resolveAnimeEpisodeForSimkl returns unchanged for non-anime`() { + val reference = TrackingMediaReference( + kind = TrackingMediaKind.SHOW, + title = "Regular Show", + ids = TrackingExternalIds(imdb = "tt1234567"), + episode = TrackingEpisode(season = 2, number = 5), + catalog = TrackingCatalogReference( + contentId = "tt1234567", + contentType = "series", + videoId = "mal:123:5", + ), + ) + + val resolved = reference.resolveAnimeEpisodeForSimkl() + + assertEquals(reference, resolved) + } + + @Test + fun `resolveAnimeEpisodeForSimkl returns unchanged for IMDB videoId prefix`() { + val reference = TrackingMediaReference( + kind = TrackingMediaKind.ANIME, + title = "Some Anime", + ids = TrackingExternalIds(imdb = "tt2560140", mal = 16498), + episode = TrackingEpisode(season = 1, number = 5), + catalog = TrackingCatalogReference( + contentId = "tt2560140", + contentType = "series", + videoId = "tt2560140:1:5", + ), + ) + + val resolved = reference.resolveAnimeEpisodeForSimkl() + + // "tt2560140" prefix is not an anime prefix → unchanged + assertEquals(reference, resolved) + } + + @Test + fun `resolveAnimeEpisodeForSimkl returns unchanged without catalog videoId`() { + val reference = TrackingMediaReference( + kind = TrackingMediaKind.ANIME, + title = "Some Anime", + ids = TrackingExternalIds(mal = 16498), + episode = TrackingEpisode(season = 1, number = 5), + catalog = null, + ) + + val resolved = reference.resolveAnimeEpisodeForSimkl() + + assertEquals(reference, resolved) + } + + // ────────────────────────────────────────────────────────────────────────── + // Scenario 7: Kitsu content IDs — same behavior as MAL. + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `kitsu contentId resolves to canonical IMDB`() { + val entry = animeEntry(simklId = 60001, imdb = "tt5311514", kitsu = 12268, seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2024-01-01T10:00:00Z", SimklEpisodeMapping(1, 1)), + SimklEpisode(2, "2024-01-01T10:30:00Z", SimklEpisodeMapping(1, 2)), + )), + )) + val snapshot = SimklSyncSnapshot(entries = listOf(entry)) + + assertTrue(snapshot.entries.any { it.matchesContentId("kitsu:12268") }) + assertTrue(snapshot.entries.any { it.matchesContentId("tt5311514") }) + assertEquals("tt5311514", snapshot.resolveCanonicalContentId("kitsu:12268")) + } + + @Test + fun `kitsu videoId resolves watched episode`() { + val entry = animeEntry(simklId = 60001, imdb = "tt5311514", kitsu = 12268, seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2024-01-01T10:00:00Z", SimklEpisodeMapping(1, 1)), + SimklEpisode(2, "2024-01-01T10:30:00Z", SimklEpisodeMapping(1, 2)), + SimklEpisode(3, null, SimklEpisodeMapping(1, 3)), + )), + )) + val snapshot = SimklSyncSnapshot(entries = listOf(entry)) + + assertTrue(isWatchedByAnimeVideoId(snapshot, "kitsu:12268:2", episode = 2)) + assertFalse(isWatchedByAnimeVideoId(snapshot, "kitsu:12268:3", episode = 3)) + } + + @Test + fun `kitsu multi-season with same IMDB merges correctly`() { + val s1 = animeEntry(simklId = 60001, imdb = "tt5311514", kitsu = 12268, seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2024-01-01T10:00:00Z", SimklEpisodeMapping(1, 1)), + SimklEpisode(12, "2024-01-12T10:00:00Z", SimklEpisodeMapping(1, 12)), + )), + )) + val s2 = animeEntry(simklId = 60002, imdb = "tt5311514", kitsu = 42422, seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2024-04-01T10:00:00Z", SimklEpisodeMapping(2, 1)), + SimklEpisode(13, "2024-04-13T10:00:00Z", SimklEpisodeMapping(2, 13)), + )), + )) + val snapshot = SimklSyncSnapshot(entries = listOf(s1, s2)) + val items = snapshot.toSimklWatchedProjection().items + + val allEps = items.filter { it.id == "tt5311514" } + assertEquals(4, allEps.size) + assertTrue(allEps.any { it.season == 1 && it.episode == 1 }) + assertTrue(allEps.any { it.season == 1 && it.episode == 12 }) + assertTrue(allEps.any { it.season == 2 && it.episode == 1 }) + assertTrue(allEps.any { it.season == 2 && it.episode == 13 }) + } + + // ────────────────────────────────────────────────────────────────────────── + // Scenario 8: IMDB contentId with seasons from multiple MAL entries + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `IMDB contentId with 3 seasons from 3 MAL entries all mapped correctly`() { + val s1 = animeEntry(simklId = 100, imdb = "tt2560140", mal = 16498, seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2023-01-01T00:00:00Z", SimklEpisodeMapping(1, 1)), + SimklEpisode(25, "2023-01-25T00:00:00Z", SimklEpisodeMapping(1, 25)), + )), + )) + val s2 = animeEntry(simklId = 101, imdb = "tt2560140", mal = 25777, seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2023-04-01T00:00:00Z", SimklEpisodeMapping(2, 1)), + SimklEpisode(12, "2023-04-12T00:00:00Z", SimklEpisodeMapping(2, 12)), + )), + )) + val s3 = animeEntry(simklId = 102, imdb = "tt2560140", mal = 36456, seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2023-07-01T00:00:00Z", SimklEpisodeMapping(3, 1)), + SimklEpisode(22, "2023-07-22T00:00:00Z", SimklEpisodeMapping(3, 22)), + )), + )) + val snapshot = SimklSyncSnapshot(entries = listOf(s1, s2, s3)) + val items = snapshot.toSimklWatchedProjection().items + + val allEps = items.filter { it.id == "tt2560140" } + assertEquals(6, allEps.size) + assertTrue(allEps.any { it.season == 1 && it.episode == 1 }) + assertTrue(allEps.any { it.season == 1 && it.episode == 25 }) + assertTrue(allEps.any { it.season == 2 && it.episode == 1 }) + assertTrue(allEps.any { it.season == 2 && it.episode == 12 }) + assertTrue(allEps.any { it.season == 3 && it.episode == 1 }) + assertTrue(allEps.any { it.season == 3 && it.episode == 22 }) + } + + // ────────────────────────────────────────────────────────────────────────── + // Scenario 9: Fully watched anime marks all alternate IDs as fully watched + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `completed anime entry marks fullyWatchedSeriesKeys for canonical ID`() { + val entry = animeEntry(simklId = 39687, imdb = "tt2560140", mal = 123, seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2023-11-14T23:00:00Z", SimklEpisodeMapping(1, 1)), + )), + )).copy(status = SimklListStatus.COMPLETED) + val snapshot = SimklSyncSnapshot(entries = listOf(entry)) + val projection = snapshot.toSimklWatchedProjection() + + // fullyWatchedSeriesKeys contains canonical ID only + assertTrue(projection.fullyWatchedSeriesKeys.any { "tt2560140" in it }) + // Alternate keys go into animeAlternateWatchedKeys instead + val extraKeys = snapshot.animeAlternateWatchedKeys() + assertTrue(extraKeys.contains(watchedItemKey("series", "mal:123"))) + assertTrue(extraKeys.contains(watchedItemKey("series", "simkl:39687"))) + } + + // ────────────────────────────────────────────────────────────────────────── + // Scenario 10: Library projection uses "anime" type for anime entries + // ────────────────────────────────────────────────────────────────────────── + + @Test + fun `library projection uses anime type for anime entries`() { + val entry = animeEntry(simklId = 39687, imdb = "tt2560140", mal = 16498) + val snapshot = SimklSyncSnapshot(entries = listOf(entry)) + val projection = snapshot.toSimklLibraryProjection() + + val item = projection.items.singleOrNull { it.id == "tt2560140" } + assertNotNull(item) + assertEquals("anime", item.type) + } + + @Test + fun `library status definitions include anime in supportedContentTypes`() { + simklLibraryStatusDefinitions.forEach { definition -> + if (definition.status != SimklListStatus.PLAN_TO_WATCH) { + assertTrue( + "anime" in definition.supportedContentTypes, + "Status ${definition.status} should support anime", + ) + } + } + // Plan to Watch also supports anime + val planToWatch = simklLibraryStatusDefinitions.single { it.status == SimklListStatus.PLAN_TO_WATCH } + assertTrue("anime" in planToWatch.supportedContentTypes) + } + + // ────────────────────────────────────────────────────────────────────────── + // Helpers + // ────────────────────────────────────────────────────────────────────────── + + /** + * Two MAL entries sharing the same IMDB, representing two seasons: + * - mal:123 → TVDB season 1 (ep 1-3 watched) + * - mal:4372 → TVDB season 2 (ep 1-2 watched) + */ + private fun snapshotWithMultiSeasonAnime(): SimklSyncSnapshot { + val season1Entry = animeEntry( + simklId = 39687, + imdb = "tt2560140", + mal = 123, + seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2023-11-14T23:00:00Z", SimklEpisodeMapping(1, 1)), + SimklEpisode(2, "2023-11-14T23:10:00Z", SimklEpisodeMapping(1, 2)), + SimklEpisode(3, "2023-11-14T23:20:00Z", SimklEpisodeMapping(1, 3)), + )), + ), + ) + val season2Entry = animeEntry( + simklId = 39688, + imdb = "tt2560140", + mal = 4372, + seasons = listOf( + SimklSeason(1, listOf( + SimklEpisode(1, "2023-12-01T20:00:00Z", SimklEpisodeMapping(2, 1)), + SimklEpisode(2, "2023-12-01T20:30:00Z", SimklEpisodeMapping(2, 2)), + )), + ), + ) + return SimklSyncSnapshot(entries = listOf(season1Entry, season2Entry)) + } + + /** + * Split season: one TVDB season spread across two MAL entries. + * - mal:11757 → TVDB S1E1-S1E14 (all 14 episodes watched) + * - mal:11759 → TVDB S1E15-S1E25 (episodes 1-5 watched as S1E15-S1E19) + */ + private fun snapshotWithSplitSeason(): SimklSyncSnapshot { + val firstHalf = animeEntry( + simklId = 50001, + imdb = "tt2250192", + mal = 11757, + seasons = listOf( + SimklSeason(1, (1..14).map { ep -> + SimklEpisode( + ep, + "2023-11-14T23:${ep.toString().padStart(2, '0')}:00Z", + SimklEpisodeMapping(1, ep), + ) + }), + ), + ) + val secondHalf = animeEntry( + simklId = 50002, + imdb = "tt2250192", + mal = 11759, + seasons = listOf( + SimklSeason(1, (1..11).map { ep -> + SimklEpisode( + ep, + if (ep <= 5) "2023-11-15T${ep.toString().padStart(2, '0')}:00:00Z" else null, + SimklEpisodeMapping(1, 14 + ep), + ) + }), + ), + ) + return SimklSyncSnapshot(entries = listOf(firstHalf, secondHalf)) + } + + private fun animeEntry( + simklId: Long, + imdb: String? = null, + mal: Long? = null, + kitsu: Long? = null, + seasons: List = emptyList(), + ): SimklLibraryEntry = SimklLibraryEntry( + mediaType = SimklMediaType.ANIME, + status = SimklListStatus.WATCHING, + lastWatchedAt = "2023-12-01T20:00:00Z", + seasons = seasons, + show = SimklMedia( + title = "Anime $simklId", + poster = "poster/$simklId", + year = 2020, + ids = buildJsonObject { + put("simkl", simklId) + imdb?.let { put("imdb", it) } + mal?.let { put("mal", it) } + kitsu?.let { put("kitsu", it) } + }, + ), + ) +} From 807f561f756196c1253687c801d1ed82f4c3fcb4 Mon Sep 17 00:00:00 2001 From: skoruppa Date: Mon, 27 Jul 2026 09:40:54 +0200 Subject: [PATCH 58/59] fix: skip series-level removal in Simkl delete to prevent wiping entire show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When reconcileSeriesWatchedState removes the series-level watched marker, it calls delete() with a WatchedItem without season/episode. This was being sent to Simkl as a show-level removal, which deleted the entire entry from the user's library. Now we filter out items without season/episode — those are local-only reconciliation markers. --- .../nuvio/app/features/simkl/SimklApplicationAdapters.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt index 7de75152..5610f69c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt @@ -100,11 +100,13 @@ object SimklWatchedSyncAdapter : TrackingWatchedProvider { override suspend fun delete(profileId: Int, items: Collection) { if (profileId != ProfileRepository.activeProfileId || items.isEmpty()) return + val episodeItems = items.filter { item -> item.season != null && item.episode != null } + if (episodeItems.isEmpty()) return // Optimistically mark video IDs as removed so fallback won't show them as watched - items.forEach { item -> item.videoId?.let(SimklAnimeWatchedFallback::markOptimisticallyRemoved) } + episodeItems.forEach { item -> item.videoId?.let(SimklAnimeWatchedFallback::markOptimisticallyRemoved) } SimklSyncRepository.ensureLoaded() val snapshot = SimklSyncRepository.state.value.snapshot - val media = items.map { item -> + val media = episodeItems.map { item -> snapshot.mediaReference( contentId = item.id, contentType = item.type, From 82159fea4763f0ed2c3385726b8388a6a9a0f5eb Mon Sep 17 00:00:00 2001 From: skoruppa Date: Mon, 27 Jul 2026 10:17:00 +0200 Subject: [PATCH 59/59] fix: next episode calculation resolves anime watched via fallback seriesPrimaryAction now accepts watchedKeys and synthesizes watched records for episodes resolved via anime videoId fallback. This fixes next-up showing S1E1 when entering via franchise parent MAL ID (e.g. mal:60636) while all episodes are correctly shown as watched in the UI. --- .../app/features/details/MetaDetailsScreen.kt | 3 +- .../details/SeriesPlaybackResolver.kt | 28 ++++++++++++++++--- .../app/features/watched/WatchedRepository.kt | 18 ++++++++++-- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index edae5183..b8076364 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -568,12 +568,13 @@ fun MetaDetailsScreen( val movieProgress = progressByVideoId[meta.id] ?.takeUnless { it.isCompleted } val cwPrefs by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() - val seriesAction = remember(watchProgressUiState.entries, watchedUiState.items, meta, todayIsoDate, cwPrefs.upNextFromFurthestEpisode) { + val seriesAction = remember(watchProgressUiState.entries, watchedUiState.items, meta, todayIsoDate, cwPrefs.upNextFromFurthestEpisode, watchedUiState.watchedKeys) { meta.seriesPrimaryAction( entries = watchProgressUiState.entries, watchedItems = watchedUiState.items, todayIsoDate = todayIsoDate, preferFurthestEpisode = cwPrefs.upNextFromFurthestEpisode, + watchedKeys = watchedUiState.watchedKeys, ) } val seriesActionVideo = remember(seriesAction, meta.id, meta.videos) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt index 8f548a3c..e6d25f27 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt @@ -2,6 +2,8 @@ package com.nuvio.app.features.details import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs +import com.nuvio.app.features.watched.watchedItemKey +import com.nuvio.app.features.watching.application.WatchingState import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watching.domain.WatchingCompletedEpisode import com.nuvio.app.features.watching.domain.WatchingContentRef @@ -144,15 +146,33 @@ internal fun MetaDetails.seriesPrimaryAction( todayIsoDate: String, preferFurthestEpisode: Boolean = true, showUnairedNextUp: Boolean = false, -): SeriesPrimaryAction? = - seriesPrimaryAction( - content = WatchingContentRef(type = type, id = id), + watchedKeys: Set = emptySet(), +): SeriesPrimaryAction? { + val content = WatchingContentRef(type = type, id = id) + val effectiveWatchedItems = buildList { + addAll(watchedItems.filter { it.type.equals(type, ignoreCase = true) && it.id.equals(id, ignoreCase = true) }) + if (watchedKeys.isNotEmpty()) { + val existingKeys = mapTo(mutableSetOf()) { watchedItemKey(it.type, it.id, it.season, it.episode) } + videos.forEach { video -> + val season = video.season ?: return@forEach + val episode = video.episode ?: return@forEach + val key = watchedItemKey(type, id, season, episode) + if (key in existingKeys) return@forEach + if (WatchingState.isEpisodeWatched(watchedKeys, type, id, video)) { + add(WatchedItem(id = id, type = type, season = season, episode = episode, name = "", markedAtEpochMs = 0L)) + } + } + } + } + return seriesPrimaryAction( + content = content, entries = entries, - watchedItems = watchedItems, + watchedItems = effectiveWatchedItems, todayIsoDate = todayIsoDate, preferFurthestEpisode = preferFurthestEpisode, showUnairedNextUp = showUnairedNextUp, ) +} internal fun MetaDetails.seriesPrimaryAction( content: WatchingContentRef, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt index 63126664..d70c98ca 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt @@ -818,6 +818,7 @@ object WatchedRepository { } else if (source.providerId != null) { // Items not found in local store (e.g. anime resolved via snapshot fallback). // Still push delete to provider so it can remove from remote. + // The observer on snapshot changes will re-pull items and update the store. pushDeleteToServer(items = items.toList(), source = source) } } @@ -1010,7 +1011,8 @@ object WatchedRepository { /** * Observes provider extra watched keys (e.g. Simkl anime alternate IDs). * When the provider's snapshot changes (after mutations, syncs), recomputes - * extra keys and re-publishes so watchedKeys stays reactive and current. + * extra keys, re-pulls watched items, and re-publishes so watchedKeys and + * items stay reactive and current. */ private fun startExtraKeysObserverIfNeeded() { if (extraKeysObserverJob != null) return @@ -1021,9 +1023,19 @@ object WatchedRepository { adapter.observeExtraWatchedKeys(currentProfileId) .distinctUntilChanged() .collectLatest { extraKeys -> - val changed = providerExtraWatchedKeys[providerId] != extraKeys - if (changed) { + val keysChanged = providerExtraWatchedKeys[providerId] != extraKeys + if (keysChanged) { providerExtraWatchedKeys[providerId] = extraKeys + // Re-pull items from provider to reflect snapshot changes + // (e.g. after remote episode removal) + val freshItems = adapter.pull( + profileId = currentProfileId, + pageSize = watchedItemsPageSize, + ) + val itemsByKey = freshItems.associateBy { item -> + watchedItemKey(item.type, item.id, item.season, item.episode) + }.toMutableMap() + providerItemsByKey[providerId] = itemsByKey publish() } }