diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 1141a6722..e1d1ca30c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -210,7 +210,6 @@ import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watchprogress.nextUpDismissKey import com.nuvio.app.features.watchprogress.toContinueWatchingItem -import com.nuvio.app.features.watching.application.SeriesWatchedReconciliationService import com.nuvio.app.features.watching.application.WatchingActions import com.nuvio.app.features.watching.application.WatchingState import kotlinx.coroutines.flow.Flow @@ -692,9 +691,6 @@ private fun MainAppContent( remember { ProfileSettingsSync.startObserving() } - remember { - SeriesWatchedReconciliationService.startObserving() - } val hapticFeedback = LocalHapticFeedback.current val focusManager = LocalFocusManager.current val coroutineScope = rememberCoroutineScope() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt index e60bb169e..31ac63abd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt @@ -82,6 +82,7 @@ fun CatalogScreen( WatchedRepository.ensureLoaded() WatchedRepository.uiState }.collectAsStateWithLifecycle() + val fullyWatchedSeriesKeys by WatchedRepository.fullyWatchedSeriesKeys.collectAsStateWithLifecycle() val initialScrollPosition = remember( target, homeCatalogSettingsUiState.hideUnreleasedContent, @@ -203,6 +204,7 @@ fun CatalogScreen( isWatched = WatchingState.isPosterWatched( watchedKeys = watchedUiState.watchedKeys, item = item, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, ), onClick = onPosterClick?.let { { it(item) } }, onLongClick = onPosterLongClick?.let { { it(item) } }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 406ed3a3e..b7a52cad4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -162,6 +162,7 @@ fun MetaDetailsScreen( WatchedRepository.ensureLoaded() WatchedRepository.uiState }.collectAsStateWithLifecycle() + val fullyWatchedSeriesKeys by WatchedRepository.fullyWatchedSeriesKeys.collectAsStateWithLifecycle() val watchProgressUiState by remember { WatchProgressRepository.ensureLoaded() WatchProgressRepository.uiState @@ -360,10 +361,11 @@ fun MetaDetailsScreen( ) { LibraryRepository.isSaved(meta.id, meta.type) } - val isWatched = remember(watchedUiState.watchedKeys, metaPreview) { + val isWatched = remember(watchedUiState.watchedKeys, fullyWatchedSeriesKeys, metaPreview) { WatchingState.isPosterWatched( watchedKeys = watchedUiState.watchedKeys, item = metaPreview, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, ) } val openLibraryListPicker = remember(meta) { @@ -411,6 +413,22 @@ fun MetaDetailsScreen( WatchProgressRepository.refreshEpisodeProgress(meta.id) } } + LaunchedEffect( + meta.id, + meta.type, + todayIsoDate, + watchedUiState.isLoaded, + watchProgressUiState.hasLoadedRemoteProgress, + watchedUiState.watchedKeys, + watchProgressUiState.entries, + ) { + if (watchedUiState.isLoaded && watchProgressUiState.hasLoadedRemoteProgress) { + WatchingActions.reconcileSeriesWatchedState( + meta = meta, + todayIsoDate = todayIsoDate, + ) + } + } val movieProgress = progressByVideoId[meta.id] ?.takeUnless { it.isCompleted } val cwPrefs by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitions.kt index ae239c202..53d8f035a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitions.kt @@ -70,6 +70,14 @@ private fun buildHomeCatalogDescriptorSignature( buildString { append(addon.displayTitle) append('|') + append(addon.enabled) + append('|') + append(addon.isRefreshing) + append('|') + append(addon.errorMessage.orEmpty()) + append('|') + append(addon.manifestUrl) + append('|') append(manifest.id) append('|') append(manifest.name) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt index 46dcc2333..bd1c424bd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt @@ -34,7 +34,7 @@ object HomeRepository { private var activeJob: Job? = null private var activeRequestKey: String? = null - private var lastRequestKey: String? = null + private var completedRequestKey: String? = null private var currentDefinitions: List = emptyList() private var cachedSections: Map = emptyMap() private var cachedCollectionHeroItems: List = emptyList() @@ -49,25 +49,28 @@ object HomeRepository { currentDefinitions = requests val requestCacheKeys = requests.mapTo(mutableSetOf(), HomeCatalogDefinition::cacheKey) cachedSections = cachedSections.filterKeys(requestCacheKeys::contains) - val requestKey = requests.joinToString(separator = "|") { request -> - request.cacheKey - } + val requestKey = requests.joinToString(separator = "|", transform = HomeCatalogDefinition::cacheKey) if (!force && activeRequestKey == requestKey && _uiState.value.isLoading) return - if (!force && requestKey == lastRequestKey && requestCacheKeys.all(cachedSections::containsKey)) { + if ( + !force && + requestKey == completedRequestKey && + requestCacheKeys.all(cachedSections::containsKey) && + requestCacheKeys.any(::hasRenderableCachedSection) + ) { if (_uiState.value.sections.isEmpty() || _uiState.value.heroItems.isEmpty()) { applyCurrentSettings() } return } - lastRequestKey = requestKey activeRequestKey = requestKey if (requests.isEmpty()) { activeJob?.cancel() activeJob = null activeRequestKey = null + completedRequestKey = requestKey cachedSections = emptyMap() lastErrorMessage = null publishCurrentState( @@ -138,6 +141,10 @@ object HomeRepository { cachedSections = loadedSections.toMap() lastErrorMessage = firstErrorMessage + if (cachedSections.values.any { section -> section.items.isNotEmpty() }) { + completedRequestKey = requestKey + } + activeRequestKey = null publishCurrentState( isLoading = false, requestKey = requestKey, @@ -153,12 +160,12 @@ object HomeRepository { fun applyCurrentSettings() { publishCurrentState( isLoading = _uiState.value.isLoading, - requestKey = activeRequestKey ?: lastRequestKey, + requestKey = activeRequestKey ?: completedRequestKey, ) ensureCollectionHeroFallback( addons = AddonRepository.uiState.value.addons.enabledAddons(), force = false, - requestKey = activeRequestKey ?: lastRequestKey, + requestKey = activeRequestKey ?: completedRequestKey, ) } @@ -166,7 +173,7 @@ object HomeRepository { activeJob?.cancel() activeJob = null activeRequestKey = null - lastRequestKey = null + completedRequestKey = null currentDefinitions = emptyList() cachedSections = emptyMap() cachedCollectionHeroItems = emptyList() @@ -178,6 +185,9 @@ object HomeRepository { _uiState.value = HomeUiState() } + private fun hasRenderableCachedSection(cacheKey: String): Boolean = + cachedSections[cacheKey]?.items?.isNotEmpty() == true + private fun publishCurrentState( isLoading: Boolean, requestKey: String?, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 325288874..dd354dd08 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -46,6 +46,8 @@ import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap import com.nuvio.app.features.trakt.shouldUseTraktProgress import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watched.WatchedRepository +import com.nuvio.app.features.watched.episodePlaybackId +import com.nuvio.app.features.watched.watchedItemKey import com.nuvio.app.features.watchprogress.CachedInProgressItem import com.nuvio.app.features.watchprogress.CachedNextUpItem import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache @@ -122,6 +124,7 @@ fun HomeScreen( val collections by CollectionRepository.collections.collectAsStateWithLifecycle() val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() + val fullyWatchedSeriesKeys by WatchedRepository.fullyWatchedSeriesKeys.collectAsStateWithLifecycle() val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle() val cloudLibraryUiState by CloudLibraryRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() @@ -853,6 +856,7 @@ fun HomeScreen( null }, watchedKeys = watchedUiState.watchedKeys, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, onPosterClick = onPosterClick, onPosterLongClick = onPosterLongClick, ) @@ -1013,6 +1017,23 @@ private suspend fun resolveHomeNextUpCandidate( } else { watchedItems } + val resolvedWatchedKeys = resolvedWatchedItems.mapTo(linkedSetOf()) { item -> + watchedItemKey(item.type, item.id, item.season, item.episode) + } + + WatchedRepository.reconcileFullyWatchedSeriesState( + meta = meta, + todayIsoDate = todayIsoDate, + isEpisodeWatched = { episode -> + watchedItemKey(meta.type, meta.id, episode.season, episode.episode) in resolvedWatchedKeys + }, + isEpisodeCompleted = { episode -> + val playbackId = meta.episodePlaybackId(episode) + resolvedProgressEntries.any { entry -> + entry.videoId == playbackId && entry.isEffectivelyCompleted + } + }, + ) val action = meta.seriesPrimaryAction( content = completedEntry.content, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt index aecd66268..7f671d116 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt @@ -24,6 +24,7 @@ fun HomeCatalogRowSection( modifier: Modifier = Modifier, entries: List = section.items, watchedKeys: Set = emptySet(), + fullyWatchedSeriesKeys: Set = emptySet(), sectionPadding: Dp? = null, onViewAllClick: (() -> Unit)? = null, onPosterClick: ((MetaPreview) -> Unit)? = null, @@ -34,6 +35,7 @@ fun HomeCatalogRowSection( section = section, entries = entries, watchedKeys = watchedKeys, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, modifier = modifier.fillMaxWidth(), sectionPadding = sectionPadding, onViewAllClick = onViewAllClick, @@ -46,6 +48,7 @@ fun HomeCatalogRowSection( section = section, entries = entries, watchedKeys = watchedKeys, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, modifier = Modifier.fillMaxWidth(), sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value), onViewAllClick = onViewAllClick, @@ -61,6 +64,7 @@ private fun HomeCatalogRowSectionContent( section: HomeCatalogSection, entries: List, watchedKeys: Set, + fullyWatchedSeriesKeys: Set, modifier: Modifier, sectionPadding: Dp, onViewAllClick: (() -> Unit)?, @@ -90,6 +94,7 @@ private fun HomeCatalogRowSectionContent( isWatched = WatchingState.isPosterWatched( watchedKeys = watchedKeys, item = item, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, ), onClick = onPosterClick?.let { { it(item) } }, onLongClick = onPosterLongClick?.let { { it(item) } }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt index 2cf53cba2..21da5ce14 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt @@ -55,6 +55,7 @@ internal fun LazyListScope.discoverContent( onGenreSelected: (String?) -> Unit, onRetry: (() -> Unit)? = null, watchedKeys: Set = emptySet(), + fullyWatchedSeriesKeys: Set = emptySet(), onPosterClick: ((MetaPreview) -> Unit)? = null, onPosterLongClick: ((MetaPreview) -> Unit)? = null, ) { @@ -117,6 +118,7 @@ internal fun LazyListScope.discoverContent( columns = columns, modifier = Modifier.padding(horizontal = 16.dp), watchedKeys = watchedKeys, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, onPosterClick = onPosterClick, onPosterLongClick = onPosterLongClick, ) @@ -197,6 +199,7 @@ private fun DiscoverGridRow( columns: Int, modifier: Modifier = Modifier, watchedKeys: Set = emptySet(), + fullyWatchedSeriesKeys: Set = emptySet(), onPosterClick: ((MetaPreview) -> Unit)? = null, onPosterLongClick: ((MetaPreview) -> Unit)? = null, ) { @@ -216,6 +219,7 @@ private fun DiscoverGridRow( isWatched = WatchingState.isPosterWatched( watchedKeys = watchedKeys, item = item, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, ), onClick = onPosterClick?.let { { it(item) } }, onLongClick = onPosterLongClick?.let { { it(item) } }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt index c26031269..4520befe5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt @@ -111,6 +111,7 @@ fun SearchScreen( }.collectAsStateWithLifecycle() val recentSearches by SearchHistoryRepository.uiState.collectAsStateWithLifecycle() val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() + val fullyWatchedSeriesKeys by WatchedRepository.fullyWatchedSeriesKeys.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() var query by rememberSaveable { mutableStateOf("") } var lastRequestedQuery by rememberSaveable { mutableStateOf(null) } @@ -305,6 +306,7 @@ fun SearchScreen( SearchRepository.refreshDiscover(addonsUiState.addons) }, watchedKeys = watchedUiState.watchedKeys, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, onPosterClick = onPosterClick, onPosterLongClick = onPosterLongClick, ) @@ -360,6 +362,7 @@ fun SearchScreen( section = section, modifier = Modifier.padding(bottom = 12.dp), watchedKeys = watchedUiState.watchedKeys, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, onPosterClick = onPosterClick, onPosterLongClick = onPosterLongClick, ) 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 9d1fcdd91..a3a1a35fb 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 @@ -2,6 +2,7 @@ package com.nuvio.app.features.watched import co.touchlab.kermit.Logger 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 @@ -26,6 +27,7 @@ import kotlinx.serialization.json.Json @Serializable private data class StoredWatchedPayload( val items: List = emptyList(), + val fullyWatchedSeriesKeys: Set = emptySet(), val lastSuccessfulPushEpochMs: Long = 0L, val deltaCursorEventId: Long = 0L, val deltaInitialized: Boolean = false, @@ -56,6 +58,8 @@ object WatchedRepository { private val _uiState = MutableStateFlow(WatchedUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private val _fullyWatchedSeriesKeys = MutableStateFlow>(emptySet()) + val fullyWatchedSeriesKeys: StateFlow> = _fullyWatchedSeriesKeys.asStateFlow() private var hasLoaded = false private var currentProfileId: Int = 1 @@ -84,6 +88,7 @@ object WatchedRepository { lastSuccessfulPushEpochMs = 0L deltaCursorEventId = 0L deltaInitialized = false + _fullyWatchedSeriesKeys.value = emptySet() _uiState.value = WatchedUiState() } @@ -105,10 +110,12 @@ object WatchedRepository { .map(WatchedItem::normalizedMarkedAt) .associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) } .toMutableMap() + _fullyWatchedSeriesKeys.value = storedPayload.fullyWatchedSeriesKeys } else { lastSuccessfulPushEpochMs = 0L deltaCursorEventId = 0L deltaInitialized = false + _fullyWatchedSeriesKeys.value = emptySet() } publish() @@ -380,14 +387,11 @@ object WatchedRepository { if (!meta.type.isSeriesLikeWatchedType()) return ensureLoaded() - val shouldMarkSeriesWatched = meta.hasWatchedAllMainSeasonEpisodes(todayIsoDate) { episode -> - isWatched( - id = meta.id, - type = meta.type, - season = episode.season, - episode = episode.episode, - ) || isEpisodeCompleted(episode) - } + val shouldMarkSeriesWatched = reconcileFullyWatchedSeriesState( + meta = meta, + todayIsoDate = todayIsoDate, + isEpisodeCompleted = isEpisodeCompleted, + ) val seriesWatchedItem = meta.toSeriesWatchedItem() val hasSeriesWatchedMarker = isWatched(id = meta.id, type = meta.type) if (shouldMarkSeriesWatched) { @@ -399,6 +403,56 @@ object WatchedRepository { } } + fun reconcileFullyWatchedSeriesState( + meta: MetaDetails, + todayIsoDate: String, + isEpisodeWatched: (MetaVideo) -> Boolean = { episode -> + isWatched( + id = meta.id, + type = meta.type, + season = episode.season, + episode = episode.episode, + ) + }, + isEpisodeCompleted: (MetaVideo) -> Boolean = { false }, + ): Boolean { + if (!meta.type.isSeriesLikeWatchedType()) return false + + ensureLoaded() + val shouldMarkSeriesWatched = meta.hasWatchedAllMainSeasonEpisodes(todayIsoDate) { episode -> + isEpisodeWatched(episode) || isEpisodeCompleted(episode) + } + updateFullyWatchedSeriesKey( + key = watchedItemKey(meta.type, meta.id), + isFullyWatched = shouldMarkSeriesWatched, + ) + return shouldMarkSeriesWatched + } + + fun updateFullyWatchedSeries( + id: String, + type: String, + isFullyWatched: Boolean, + ) { + if (!type.isSeriesLikeWatchedType()) return + ensureLoaded() + updateFullyWatchedSeriesKey( + key = watchedItemKey(type, id), + isFullyWatched = isFullyWatched, + ) + } + + private fun updateFullyWatchedSeriesKey( + key: String, + isFullyWatched: Boolean, + ) { + val current = _fullyWatchedSeriesKeys.value + val updated = if (isFullyWatched) current + key else current - key + if (updated == current) return + _fullyWatchedSeriesKeys.value = updated + persist() + } + private fun pushMarksToServer( items: Collection, traktHistorySync: WatchedTraktHistorySync, @@ -454,6 +508,7 @@ object WatchedRepository { items = itemsByKey.values .map(WatchedItem::normalizedMarkedAt) .sortedByDescending { it.markedAtEpochMs }, + fullyWatchedSeriesKeys = _fullyWatchedSeriesKeys.value, lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, deltaCursorEventId = deltaCursorEventId, deltaInitialized = deltaInitialized, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/SeriesWatchedReconciliationService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/SeriesWatchedReconciliationService.kt deleted file mode 100644 index fe0958128..000000000 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/SeriesWatchedReconciliationService.kt +++ /dev/null @@ -1,218 +0,0 @@ -package com.nuvio.app.features.watching.application - -import co.touchlab.kermit.Logger -import com.nuvio.app.features.details.MetaDetailsRepository -import com.nuvio.app.features.profiles.ProfileRepository -import com.nuvio.app.features.watched.WatchedItem -import com.nuvio.app.features.watched.WatchedRepository -import com.nuvio.app.features.watched.episodePlaybackId -import com.nuvio.app.features.watched.watchedItemKey -import com.nuvio.app.features.watchprogress.CurrentDateProvider -import com.nuvio.app.features.watchprogress.WatchProgressEntry -import com.nuvio.app.features.watchprogress.WatchProgressRepository -import com.nuvio.app.features.watchprogress.WatchProgressUiState -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit - -private const val SERIES_RECONCILIATION_DEBOUNCE_MS = 500L -private const val SERIES_RECONCILIATION_BATCH_LIMIT = 24 -private const val SERIES_RECONCILIATION_CONCURRENCY = 3 - -private data class SeriesWatchedKey( - val profileId: Int, - val type: String, - val id: String, -) - -private data class SeriesReconciliationCandidate( - val key: SeriesWatchedKey, - val signature: String, - val latestUpdatedAt: Long, -) - -object SeriesWatchedReconciliationService { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private val log = Logger.withTag("SeriesWatchedReconciliation") - private val lastReconciledSignatures = linkedMapOf() - private var observeJob: Job? = null - - @OptIn(FlowPreview::class) - fun startObserving() { - if (observeJob?.isActive == true) return - - observeJob = scope.launch { - combine( - WatchedRepository.uiState, - WatchProgressRepository.uiState, - ) { watchedState, progressState -> - if (!watchedState.isLoaded || !progressState.hasLoadedRemoteProgress) { - emptyList() - } else { - buildReconciliationCandidates( - watchedItems = watchedState.items, - progressState = progressState, - ) - } - } - .distinctUntilChanged() - .debounce(SERIES_RECONCILIATION_DEBOUNCE_MS) - .collectLatest { candidates -> - reconcile(candidates) - } - } - } - - fun clear() { - observeJob?.cancel() - observeJob = null - lastReconciledSignatures.clear() - } - - private suspend fun reconcile(candidates: List) { - if (candidates.isEmpty()) return - val activeProfileId = ProfileRepository.activeProfileId - val pending = candidates - .filter { candidate -> lastReconciledSignatures[candidate.key] != candidate.signature } - .sortedByDescending(SeriesReconciliationCandidate::latestUpdatedAt) - .take(SERIES_RECONCILIATION_BATCH_LIMIT) - if (pending.isEmpty()) return - - val todayIsoDate = CurrentDateProvider.todayIsoDate() - val progressByVideoId = WatchProgressRepository.uiState.value.byVideoId - val semaphore = Semaphore(SERIES_RECONCILIATION_CONCURRENCY) - - coroutineScope { - pending.map { candidate -> - async { - semaphore.withPermit { - reconcileCandidate( - candidate = candidate, - activeProfileId = activeProfileId, - todayIsoDate = todayIsoDate, - progressByVideoId = progressByVideoId, - ) - } - } - }.awaitAll() - } - } - - private suspend fun reconcileCandidate( - candidate: SeriesReconciliationCandidate, - activeProfileId: Int, - todayIsoDate: String, - progressByVideoId: Map, - ) { - if (ProfileRepository.activeProfileId != activeProfileId) return - - val meta = try { - MetaDetailsRepository.fetch( - type = candidate.key.type, - id = candidate.key.id, - ) - } catch (error: Throwable) { - if (error is CancellationException) throw error - log.d { "Skipping series watched reconciliation for ${candidate.key.id}: ${error.message}" } - null - } ?: return - - if (ProfileRepository.activeProfileId != activeProfileId) return - - WatchedRepository.reconcileSeriesWatchedState( - meta = meta, - todayIsoDate = todayIsoDate, - isEpisodeCompleted = { episode -> - progressByVideoId[meta.episodePlaybackId(episode)]?.isEffectivelyCompleted == true - }, - ) - lastReconciledSignatures[candidate.key] = candidate.signature - } - - private fun buildReconciliationCandidates( - watchedItems: List, - progressState: WatchProgressUiState, - ): List { - val groupedWatched = watchedItems - .filter { item -> item.type.isSeriesLikeType() } - .groupBy { item -> - SeriesWatchedKey( - profileId = ProfileRepository.activeProfileId, - type = item.type, - id = item.id, - ) - } - val groupedProgress = progressState.entries - .filter { entry -> entry.parentMetaType.isSeriesLikeType() && entry.isEpisode } - .groupBy { entry -> - SeriesWatchedKey( - profileId = ProfileRepository.activeProfileId, - type = entry.parentMetaType, - id = entry.parentMetaId, - ) - } - - return (groupedWatched.keys + groupedProgress.keys) - .mapNotNull { key -> - val watched = groupedWatched[key].orEmpty() - val progress = groupedProgress[key].orEmpty() - val hasSeriesMarker = watched.any { item -> item.season == null && item.episode == null } - val watchedEpisodes = watched - .filter { item -> item.season != null && item.episode != null } - .sortedWith(compareBy { it.season ?: -1 }.thenBy { it.episode ?: -1 }) - val completedProgress = progress - .filter(WatchProgressEntry::isEffectivelyCompleted) - .sortedWith(compareBy { it.seasonNumber ?: -1 }.thenBy { it.episodeNumber ?: -1 }) - - if (!hasSeriesMarker && watchedEpisodes.isEmpty() && completedProgress.isEmpty()) { - return@mapNotNull null - } - - SeriesReconciliationCandidate( - key = key, - latestUpdatedAt = maxOf( - watched.maxOfOrNull(WatchedItem::markedAtEpochMs) ?: 0L, - progress.maxOfOrNull(WatchProgressEntry::lastUpdatedEpochMs) ?: 0L, - ), - signature = buildString { - append("series=") - append(hasSeriesMarker) - append("|watched=") - watchedEpisodes.forEach { item -> - append(item.season) - append(":") - append(item.episode) - append(":") - append(item.markedAtEpochMs) - append(",") - } - append("|progress=") - completedProgress.forEach { entry -> - append(entry.seasonNumber) - append(":") - append(entry.episodeNumber) - append(":") - append(entry.lastUpdatedEpochMs) - append(",") - } - }, - ) - } - } -} - -private fun String.isSeriesLikeType(): Boolean = - trim().lowercase() in setOf("series", "show", "tv", "tvshow") diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt index 984dfbec7..0f7f51168 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt @@ -36,6 +36,11 @@ object WatchingActions { if (meta == null) { if (isCurrentlyWatched) { WatchedRepository.unmarkWatched(preview.toWatchedItem(markedAtEpochMs = 0L)) + WatchedRepository.updateFullyWatchedSeries( + id = preview.id, + type = preview.type, + isFullyWatched = false, + ) } return } @@ -45,6 +50,11 @@ object WatchingActions { if (releasedMainEpisodes.isEmpty()) { if (isCurrentlyWatched) { WatchedRepository.unmarkWatched(meta.toSeriesWatchedItem()) + WatchedRepository.updateFullyWatchedSeries( + id = meta.id, + type = meta.type, + isFullyWatched = false, + ) } return } @@ -55,8 +65,18 @@ object WatchingActions { if (isCurrentlyWatched) { WatchedRepository.unmarkWatched(seriesItems) + WatchedRepository.updateFullyWatchedSeries( + id = meta.id, + type = meta.type, + isFullyWatched = false, + ) } else { WatchedRepository.markWatched(seriesItems) + WatchedRepository.updateFullyWatchedSeries( + id = meta.id, + type = meta.type, + isFullyWatched = true, + ) WatchProgressRepository.clearProgress( releasedMainEpisodes.map(meta::episodePlaybackId), ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt index c0f1474f6..f098c65fd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt @@ -18,7 +18,12 @@ object WatchingState { fun isPosterWatched( watchedKeys: Set, item: MetaPreview, - ): Boolean = watchedKeys.contains(watchedItemKey(item.type, item.id)) + fullyWatchedSeriesKeys: Set = emptySet(), + ): Boolean { + val posterKey = watchedItemKey(item.type, item.id) + if (watchedKeys.contains(posterKey)) return true + return item.type.isSeriesLikePosterType() && fullyWatchedSeriesKeys.contains(posterKey) + } fun isEpisodeWatched( watchedKeys: Set, @@ -82,6 +87,9 @@ object WatchingState { ): List = progressEntries.continueWatchingEntries() } +private fun String.isSeriesLikePosterType(): Boolean = + trim().lowercase() in setOf("series", "show", "tv", "tvshow") + private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord = normalizedCompletion().let { entry -> WatchingProgressRecord(