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] 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 7a9a39f3b..a1226d1cf 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 f9226a59f..6f5721f2c 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 1b6c15384..4ae2d43a2 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 55041ec1a..739ae3de3 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 211ec9c71..3402c7824 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 2bd43d27f..d875ed87f 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 b2490f75b..755e14bf1 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 8a83ff41a..512e8f676 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 974bf6c9f..657fc8b28 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 cea8d6ff9..e7de03c55 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(