diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index e0967ca0..b42f920b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -944,6 +944,19 @@ private fun MainAppContent( } } + DisposableEffect(authState, profileState.activeProfile?.profileIndex) { + val authenticatedState = authState as? AuthState.Authenticated + val activeProfileId = profileState.activeProfile?.profileIndex + if (authenticatedState != null && !authenticatedState.isAnonymous && activeProfileId != null) { + SyncManager.startPeriodicNuvioSyncPull(activeProfileId) + } else { + SyncManager.stopPeriodicNuvioSyncPull() + } + onDispose { + SyncManager.stopPeriodicNuvioSyncPull() + } + } + LaunchedEffect(authState, profileState.activeProfile?.profileIndex) { val authenticatedState = authState as? AuthState.Authenticated ?: return@LaunchedEffect if (authenticatedState.isAnonymous) return@LaunchedEffect 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 24970f26..7be4bb6c 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 @@ -7,11 +7,16 @@ import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.collection.CollectionSyncService import com.nuvio.app.features.home.HomeCatalogSettingsSyncService +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.TraktCredentialSync 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.watched.WatchedRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository import kotlinx.coroutines.CoroutineScope @@ -19,15 +24,19 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch private const val FOREGROUND_PULL_DELAY_MS = 2500L private const val FOREGROUND_PULL_MIN_INTERVAL_MS = 30 * 60_000L +private const val PERIODIC_NUVIO_SYNC_PULL_INTERVAL_MS = 60_000L object SyncManager { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val log = Logger.withTag("SyncManager") private var foregroundPullJob: Job? = null + private var periodicNuvioSyncPullJob: Job? = null + private var periodicNuvioSyncProfileId: Int? = null private var lastForegroundPullAtMs: Long = 0L fun pullAllForProfile(profileId: Int) { @@ -36,7 +45,7 @@ object SyncManager { if (authState.isAnonymous) return scope.launch { - log.i { "pullAllForProfile($profileId) — auth=${(authState as AuthState.Authenticated).isAnonymous}" } + log.i { "pullAllForProfile($profileId) — auth=${authState.isAnonymous}" } log.i { "pullAllForProfile — pulling addons first (await)..." } runCatching { AddonRepository.pullFromServer(profileId) } @@ -105,6 +114,68 @@ object SyncManager { } } + fun startPeriodicNuvioSyncPull(profileId: Int) { + val authState = AuthRepository.state.value + if (authState !is AuthState.Authenticated || authState.isAnonymous) { + stopPeriodicNuvioSyncPull() + return + } + if (periodicNuvioSyncPullJob?.isActive == true && periodicNuvioSyncProfileId == profileId) return + + stopPeriodicNuvioSyncPull() + periodicNuvioSyncProfileId = profileId + periodicNuvioSyncPullJob = scope.launch { + while (isActive) { + delay(PERIODIC_NUVIO_SYNC_PULL_INTERVAL_MS) + + val currentAuthState = AuthRepository.state.value + if (currentAuthState !is AuthState.Authenticated || currentAuthState.isAnonymous) { + continue + } + if (ProfileRepository.activeProfileId != profileId) { + continue + } + + TraktAuthRepository.ensureLoaded() + TraktSettingsRepository.ensureLoaded() + + val traktAuthenticated = TraktAuthRepository.isAuthenticated.value + val settings = TraktSettingsRepository.uiState.value + val shouldPullLibrary = effectiveLibrarySourceMode( + isAuthenticated = traktAuthenticated, + source = settings.librarySourceMode, + ) == LibrarySourceMode.LOCAL + val shouldPullWatchProgress = !shouldUseTraktProgress( + isAuthenticated = traktAuthenticated, + source = settings.watchProgressSource, + ) + + if (!shouldPullLibrary && !shouldPullWatchProgress) { + continue + } + + log.i { + "Periodic Nuvio sync pull profile=$profileId " + + "library=$shouldPullLibrary watchProgress=$shouldPullWatchProgress" + } + if (shouldPullLibrary) { + runCatching { LibraryRepository.pullFromServer(profileId) } + .onFailure { log.e(it) { "Periodic Nuvio library pull failed" } } + } + if (shouldPullWatchProgress) { + runCatching { WatchProgressRepository.pullFromServer(profileId) } + .onFailure { log.e(it) { "Periodic Nuvio watch progress pull failed" } } + } + } + } + } + + fun stopPeriodicNuvioSyncPull() { + periodicNuvioSyncPullJob?.cancel() + periodicNuvioSyncPullJob = null + periodicNuvioSyncProfileId = null + } + fun requestRealtimeSurfacePull(profileId: Int, surface: String) { val authState = AuthRepository.state.value if (authState !is AuthState.Authenticated || authState.isAnonymous) return diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt index dcf6a209..45ed9c10 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt @@ -51,7 +51,6 @@ private const val METADATA_FETCH_TIMEOUT_MS = 3_500L private const val METADATA_FETCH_CONCURRENCY = 5 private const val METADATA_HYDRATION_LIMIT = 110 private const val REFRESH_BASE_INTERVAL_MS = 60L * 1000L -private const val REFRESH_MAX_INTERVAL_MS = 15L * 60L * 1000L private const val EPISODE_PROGRESS_CACHE_TTL_MS = 30L * 60L * 1000L private const val EPISODE_PROGRESS_FETCH_THROTTLE_MS = 60L * 1000L private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L @@ -80,7 +79,6 @@ object TraktProgressRepository { private val refreshJobMutex = Mutex() private var inFlightRefresh: Deferred? = null private var refreshIntervalMs = REFRESH_BASE_INTERVAL_MS - private var consecutiveRefreshFailures = 0 private var lastKnownMoviesWatchedAt: String? = null private var lastKnownEpisodeActivityFingerprint: String? = null private var lastKnownActivityFingerprint: String? = null @@ -103,17 +101,17 @@ object TraktProgressRepository { source = TraktSettingsRepository.uiState.value.watchProgressSource, ) ) { - updateRefreshBackoff(success = true) + resetRefreshInterval() continue } - val success = runCatching { + runCatching { refreshIfActivityChanged() }.onFailure { error -> if (error is CancellationException) throw error log.w { "Periodic Trakt activity refresh failed: ${error.message}" } - }.isSuccess - updateRefreshBackoff(success = success) + } + resetRefreshInterval() } } } @@ -272,23 +270,14 @@ object TraktProgressRepository { return changed } - private fun updateRefreshBackoff(success: Boolean) { - if (success) { - consecutiveRefreshFailures = 0 - refreshIntervalMs = REFRESH_BASE_INTERVAL_MS - return - } - - consecutiveRefreshFailures += 1 - refreshIntervalMs = (REFRESH_BASE_INTERVAL_MS shl (consecutiveRefreshFailures - 1)) - .coerceAtMost(REFRESH_MAX_INTERVAL_MS) + private fun resetRefreshInterval() { + refreshIntervalMs = REFRESH_BASE_INTERVAL_MS } private fun resetActivitySnapshot() { lastKnownMoviesWatchedAt = null lastKnownEpisodeActivityFingerprint = null lastKnownActivityFingerprint = null - consecutiveRefreshFailures = 0 refreshIntervalMs = REFRESH_BASE_INTERVAL_MS }