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 e0ec3829a..26f458489 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 06c102f8c..3aa17583c 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 000000000..b327b8b36 --- /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 026791e1b..184dc1c6e 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 7c05b9a18..2bd43d27f 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 1e77f43ff..7ad50c42d 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 b7bbfb680..94b59127e 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 6668e3e9c..8a83ff41a 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 cd27d1375..cea8d6ff9 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 39003816f..a75017e71 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()