diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.android.kt index 880700f4..bd123c85 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.android.kt @@ -1,8 +1,16 @@ package com.nuvio.app.features.watchprogress import java.time.LocalDate +import java.time.ZoneId actual object CurrentDateProvider { actual fun todayIsoDate(): String = LocalDate.now().toString() -} + actual fun localStartOfDayEpochMs(isoDate: String): Long? = + runCatching { + LocalDate.parse(isoDate) + .atStartOfDay(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + }.getOrNull() +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 3420ef96..49217a04 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -1918,7 +1918,10 @@ private fun MainAppContent( onCloudFilePlay = { item, file -> coroutineScope.launch { val resumeItem = WatchProgressRepository - .progressForVideo(item.playbackVideoId(file)) + .progressForVideo( + videoId = item.playbackVideoId(file), + parentMetaId = item.id, + ) ?.takeIf { it.isResumable } ?.toContinueWatchingItem() if ( 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 6bfaa3b7..5fc77649 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 @@ -179,8 +179,8 @@ fun MetaDetailsScreen( WatchProgressRepository.ensureLoaded() WatchProgressRepository.uiState }.collectAsStateWithLifecycle() - val progressByVideoId = remember(watchProgressUiState.entries) { - watchProgressUiState.byVideoId + val progressByVideoId = remember(watchProgressUiState.entries, id) { + watchProgressUiState.byVideoIdForContent(id) } val playerSettingsUiState by remember { PlayerSettingsRepository.ensureLoaded() @@ -695,7 +695,12 @@ fun MetaDetailsScreen( fallbackVideoId = video.id, ) val streamVideoId = video.id.takeIf { it.isNotBlank() } ?: playbackVideoId - val savedProgress = watchProgressUiState.byVideoId[streamVideoId] + val savedProgress = watchProgressUiState.progressForVideo( + videoId = streamVideoId, + parentMetaId = meta.id, + seasonNumber = season, + episodeNumber = episode, + ) ?.takeUnless { it.isCompleted } onPlay?.invoke( meta.type, @@ -724,7 +729,12 @@ fun MetaDetailsScreen( fallbackVideoId = video.id, ) val streamVideoId = video.id.takeIf { it.isNotBlank() } ?: playbackVideoId - val savedProgress = watchProgressUiState.byVideoId[streamVideoId] + val savedProgress = watchProgressUiState.progressForVideo( + videoId = streamVideoId, + parentMetaId = meta.id, + seasonNumber = season, + episodeNumber = episode, + ) ?.takeUnless { it.isCompleted } onPlayManually?.invoke( meta.type, 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 0cb70075..1d2d048f 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 @@ -59,6 +59,7 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode import com.nuvio.app.features.watchprogress.isMalformedNextUpSeedContentId import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching import com.nuvio.app.features.watchprogress.nextUpDismissKey +import com.nuvio.app.features.watchprogress.resolvedProgressKey import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueWatching import com.nuvio.app.features.watchprogress.WatchProgressClock @@ -78,6 +79,7 @@ import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.home.components.HomeCollectionRowSection import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.launch @@ -168,6 +170,10 @@ fun HomeScreen( val isTraktProgressActive = effectiveWatchProgressSource == WatchProgressSource.TRAKT + val nextUpWatchedItems = remember(watchedUiState.items, isTraktProgressActive) { + if (isTraktProgressActive) emptyList() else watchedUiState.items + } + val effectiveWatchProgressEntries = remember( watchProgressUiState.entries, isTraktProgressActive, @@ -188,7 +194,7 @@ fun HomeScreen( val allNextUpSeedCandidates = remember( watchProgressUiState.entries, - watchedUiState.items, + nextUpWatchedItems, isTraktProgressActive, continueWatchingPreferences.upNextFromFurthestEpisode, ) { @@ -197,14 +203,9 @@ fun HomeScreen( } else { watchProgressUiState.entries } - val filteredWatchedItems = if (isTraktProgressActive) { - watchedUiState.items.filter { !WatchProgressRepository.isDroppedShow(it.id) } - } else { - watchedUiState.items - } buildHomeNextUpSeedCandidates( progressEntries = filteredEntries, - watchedItems = filteredWatchedItems, + watchedItems = nextUpWatchedItems, isTraktProgressActive = isTraktProgressActive, preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, nowEpochMs = WatchProgressClock.nowEpochMs(), @@ -299,15 +300,17 @@ fun HomeScreen( ) } val shouldValidateMissingNextUpSeeds = remember( - isTraktProgressActive, watchProgressUiState.hasLoadedRemoteProgress, watchedUiState.isLoaded, + watchedUiState.hasLoadedRemoteItems, + isTraktProgressActive, ) { - if (isTraktProgressActive) { - watchProgressUiState.hasLoadedRemoteProgress - } else { - watchedUiState.isLoaded - } + isHomeNextUpSeedSourceLoaded( + isTraktProgressActive = isTraktProgressActive, + hasLoadedRemoteProgress = watchProgressUiState.hasLoadedRemoteProgress, + hasLoadedWatchedItems = watchedUiState.isLoaded, + hasLoadedRemoteWatchedItems = watchedUiState.hasLoadedRemoteItems, + ) } val cachedNextUpItems = remember( cachedSnapshots.first, @@ -332,8 +335,16 @@ fun HomeScreen( val currentSeed = currentNextUpSeedByContentId[cached.contentId] if (currentSeed != null) { val (currentSeason, currentEpisode) = currentSeed - val seedChanged = currentSeason != cached.seedSeason || currentEpisode != cached.seedEpisode - if (seedChanged) return@mapNotNull null + if ( + hasHomeNextUpSeedChangedFromCache( + currentSeason = currentSeason, + currentEpisode = currentEpisode, + cachedSeason = cached.seedSeason, + cachedEpisode = cached.seedEpisode, + ) + ) { + return@mapNotNull null + } } if ( isTraktProgressActive && @@ -346,7 +357,7 @@ fun HomeScreen( if (nextUpDismissKey(cached.contentId, cached.seedSeason, cached.seedEpisode) in continueWatchingPreferences.dismissedNextUpKeys) { return@mapNotNull null } - if (!cached.hasAired && !continueWatchingPreferences.showUnairedNextUp) { + if (!cachedNextUpHasAired(cached) && !continueWatchingPreferences.showUnairedNextUp) { return@mapNotNull null } if (isTraktProgressActive && WatchProgressRepository.isDroppedShow(cached.contentId)) { @@ -366,7 +377,7 @@ fun HomeScreen( if (isTraktProgressActive && WatchProgressRepository.isDroppedShow(cached.contentId)) { return@mapNotNull null } - cached.videoId to cached.toContinueWatchingItem() + cached.resolvedProgressKey() to cached.toContinueWatchingItem() }.toMap() } @@ -377,6 +388,7 @@ fun HomeScreen( activeNextUpSeedContentIds, currentNextUpSeedByContentId, shouldValidateMissingNextUpSeeds, + processedNextUpContentIds, ) { val liveNextUpItems = filterNextUpItemsByCurrentSeeds( nextUpItemsBySeries = nextUpItemsBySeries, @@ -390,14 +402,11 @@ fun HomeScreen( item.nextUpSeedEpisodeNumber, ) !in continueWatchingPreferences.dismissedNextUpKeys } - if (liveNextUpItems.isNotEmpty()) { - liveNextUpItems.mapValues { (contentId, pair) -> - val cachedItem = cachedNextUpItems[contentId]?.second - pair.first to pair.second.withFallbackMetadata(cachedItem) - } - } else { - cachedNextUpItems - } + mergeHomeNextUpItemsWithCache( + resolvedItems = liveNextUpItems, + cachedItems = cachedNextUpItems, + conclusivelyProcessedContentIds = processedNextUpContentIds, + ) } val continueWatchingItems = remember( @@ -410,7 +419,7 @@ fun HomeScreen( ) { buildHomeContinueWatchingItems( visibleEntries = visibleContinueWatchingEntries, - cachedInProgressByVideoId = cachedInProgressItems, + cachedInProgressByProgressKey = cachedInProgressItems, nextUpItemsBySeries = effectivNextUpItems, nextUpSuppressedSeriesIds = nextUpSuppressedSeriesIds, sortMode = continueWatchingPreferences.sortMode, @@ -421,9 +430,6 @@ fun HomeScreen( val enabledAddons = remember(addonsUiState.addons) { addonsUiState.addons.enabledAddons() } - val isRefreshingEnabledAddons = remember(enabledAddons) { - enabledAddons.any { addon -> addon.isRefreshing } - } val availableManifests = remember(enabledAddons) { enabledAddons.mapNotNull { addon -> addon.manifest } } @@ -434,6 +440,27 @@ fun HomeScreen( .map { manifest -> manifest.transportUrl } .sorted() } + val metaProviderReadinessKey = remember(enabledAddons) { + enabledAddons + .sortedBy { addon -> addon.manifestUrl } + .joinToString(separator = "|") { addon -> + "${addon.manifestUrl}:${addon.manifest != null}:${addon.isRefreshing}:${addon.errorMessage.orEmpty()}" + } + } + var nextUpResolutionRetryAttempt by remember( + activeProfileId, + effectiveWatchProgressSource, + completedSeriesCandidates, + metaProviderKey, + metaProviderReadinessKey, + networkStatusUiState.condition, + continueWatchingPreferences.showUnairedNextUp, + continueWatchingPreferences.upNextFromFurthestEpisode, + continueWatchingPreferences.dismissedNextUpKeys, + cwCacheGeneration, + ) { + mutableStateOf(0) + } val catalogRefreshKey = remember(enabledAddons) { buildHomeCatalogRefreshSignature(enabledAddons) @@ -456,17 +483,33 @@ fun HomeScreen( LaunchedEffect( completedSeriesCandidates, metaProviderKey, + metaProviderReadinessKey, + networkStatusUiState.condition, + nextUpResolutionRetryAttempt, continueWatchingPreferences.showUnairedNextUp, continueWatchingPreferences.upNextFromFurthestEpisode, - isRefreshingEnabledAddons, + continueWatchingPreferences.dismissedNextUpKeys, watchProgressSeedKey, visibleContinueWatchingEntries, - watchedUiState.items, + nextUpWatchedItems, watchedUiState.isLoaded, + watchedUiState.hasLoadedRemoteItems, + watchProgressUiState.hasLoadedRemoteProgress, activeProfileId, effectiveWatchProgressSource, cwCacheGeneration, ) { + if ( + !isHomeNextUpSeedSourceLoaded( + isTraktProgressActive = isTraktProgressActive, + hasLoadedRemoteProgress = watchProgressUiState.hasLoadedRemoteProgress, + hasLoadedWatchedItems = watchedUiState.isLoaded, + hasLoadedRemoteWatchedItems = watchedUiState.hasLoadedRemoteItems, + ) + ) { + return@LaunchedEffect + } + if (completedSeriesCandidates.isEmpty()) { nextUpItemsBySeries = emptyMap() processedNextUpContentIds = emptySet() @@ -482,14 +525,6 @@ fun HomeScreen( return@LaunchedEffect } - if (!isTraktProgressActive && !watchedUiState.isLoaded) { - return@LaunchedEffect - } - - if (isRefreshingEnabledAddons) { - return@LaunchedEffect - } - withContext(Dispatchers.Default) { val cachedResolvedNextUpItems = completedSeriesCandidates.mapNotNull { candidate -> val cached = cachedNextUpItems[candidate.content.id] ?: return@mapNotNull null @@ -500,6 +535,9 @@ fun HomeScreen( ) { return@mapNotNull null } + if (!hasUsableHomeNextUpMetadata(item)) { + return@mapNotNull null + } candidate.content.id to cached }.toMap() val candidatesToResolve = completedSeriesCandidates.filter { candidate -> @@ -510,17 +548,20 @@ fun HomeScreen( val deferredResolutionCandidates = resolutionPlan.deferredCandidates val seedLastWatchedMap = completedSeriesCandidates.associate { it.content.id to it.markedAtEpochMs } if (candidatesToResolve.isEmpty()) { + val cachedResults = mergeHomeNextUpItemsWithCache( + resolvedItems = cachedResolvedNextUpItems, + cachedItems = cachedNextUpItems, + conclusivelyProcessedContentIds = cachedResolvedNextUpItems.keys, + ) withContext(Dispatchers.Main) { - nextUpItemsBySeries = cachedResolvedNextUpItems - processedNextUpContentIds = completedSeriesCandidates.mapTo(mutableSetOf()) { candidate -> - candidate.content.id - } + nextUpItemsBySeries = cachedResults + processedNextUpContentIds = cachedResolvedNextUpItems.keys } saveContinueWatchingSnapshots( profileId = activeProfileId, source = effectiveWatchProgressSource, cacheGeneration = cwCacheGeneration, - nextUpItemsBySeries = cachedResolvedNextUpItems, + nextUpItemsBySeries = cachedResults, visibleContinueWatchingEntries = visibleContinueWatchingEntries, todayIsoDate = CurrentDateProvider.todayIsoDate(), seedLastWatchedMap = seedLastWatchedMap, @@ -528,10 +569,6 @@ fun HomeScreen( return@withContext } - if (metaProviderKey.isEmpty()) { - return@withContext - } - val todayIsoDate = CurrentDateProvider.todayIsoDate() val semaphore = Semaphore(NEXT_UP_RESOLUTION_CONCURRENCY) val freshResults = mutableMapOf>() @@ -545,12 +582,13 @@ fun HomeScreen( val results = Channel(Channel.UNLIMITED) candidates.forEach { completedEntry -> launch { - val resolved = try { + val attempt = try { semaphore.withPermit { resolveHomeNextUpCandidate( completedEntry = completedEntry, watchProgressEntries = watchProgressUiState.entries, - watchedItems = watchedUiState.items, + watchedItems = nextUpWatchedItems, + cachedFallbackItem = cachedNextUpItems[completedEntry.content.id]?.second, todayIsoDate = todayIsoDate, preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp, @@ -560,12 +598,12 @@ fun HomeScreen( } } catch (error: Throwable) { if (error is CancellationException) throw error - null + HomeNextUpResolutionAttempt.transientFailure() } results.send( HomeNextUpCandidateResolution( candidate = completedEntry, - resolved = resolved, + attempt = attempt, ), ) } @@ -573,24 +611,29 @@ fun HomeScreen( repeat(candidates.size) { val resolution = results.receive() - processedFreshContentIds += resolution.candidate.content.id + if (resolution.attempt.isConclusive) { + processedFreshContentIds += resolution.candidate.content.id + } var changed = false - resolution.resolved?.let { (contentId, item) -> + resolution.attempt.resolved?.let { (contentId, item) -> if (cachedResolvedNextUpItems.size + freshResults.size < HomeContinueWatchingMaxRecentProgressItems) { val previous = freshResults.put(contentId, item) changed = previous != item } } - if (changed) { - val progressiveResults = cachedResolvedNextUpItems + freshResults + if (changed || resolution.attempt.isConclusive) { + val resolvedResults = cachedResolvedNextUpItems + freshResults + val conclusiveContentIds = cachedResolvedNextUpItems.keys + processedFreshContentIds + val progressiveResults = mergeHomeNextUpItemsWithCache( + resolvedItems = resolvedResults, + cachedItems = cachedNextUpItems, + conclusivelyProcessedContentIds = conclusiveContentIds, + ) withContext(Dispatchers.Main) { nextUpItemsBySeries = progressiveResults - processedNextUpContentIds = ( - cachedResolvedNextUpItems.keys + - processedFreshContentIds - ).toSet() + processedNextUpContentIds = conclusiveContentIds } saveContinueWatchingSnapshots( profileId = activeProfileId, @@ -607,15 +650,18 @@ fun HomeScreen( results.close() } - resolveCandidatesStreaming(resolutionCandidates) + resolveCandidatesStreaming(candidates = resolutionCandidates) - val results = cachedResolvedNextUpItems + freshResults + val resolvedResults = cachedResolvedNextUpItems + freshResults + val conclusiveContentIds = cachedResolvedNextUpItems.keys + processedFreshContentIds + val results = mergeHomeNextUpItemsWithCache( + resolvedItems = resolvedResults, + cachedItems = cachedNextUpItems, + conclusivelyProcessedContentIds = conclusiveContentIds, + ) withContext(Dispatchers.Main) { nextUpItemsBySeries = results - processedNextUpContentIds = ( - cachedResolvedNextUpItems.keys + - processedFreshContentIds - ).toSet() + processedNextUpContentIds = conclusiveContentIds } saveContinueWatchingSnapshots( @@ -628,29 +674,50 @@ fun HomeScreen( seedLastWatchedMap = seedLastWatchedMap, ) - if (deferredResolutionCandidates.isEmpty()) { - return@withContext + if (deferredResolutionCandidates.isNotEmpty()) { + resolveCandidatesStreaming( + candidates = deferredResolutionCandidates, + ) + + val deferredResolvedResults = cachedResolvedNextUpItems + freshResults + val deferredConclusiveContentIds = cachedResolvedNextUpItems.keys + processedFreshContentIds + val deferredResults = mergeHomeNextUpItemsWithCache( + resolvedItems = deferredResolvedResults, + cachedItems = cachedNextUpItems, + conclusivelyProcessedContentIds = deferredConclusiveContentIds, + ) + withContext(Dispatchers.Main) { + nextUpItemsBySeries = deferredResults + processedNextUpContentIds = deferredConclusiveContentIds + } + saveContinueWatchingSnapshots( + profileId = activeProfileId, + source = effectiveWatchProgressSource, + cacheGeneration = cwCacheGeneration, + nextUpItemsBySeries = deferredResults, + visibleContinueWatchingEntries = visibleContinueWatchingEntries, + todayIsoDate = todayIsoDate, + seedLastWatchedMap = seedLastWatchedMap, + ) } - resolveCandidatesStreaming(deferredResolutionCandidates) - - val deferredResults = cachedResolvedNextUpItems + freshResults - withContext(Dispatchers.Main) { - nextUpItemsBySeries = deferredResults - processedNextUpContentIds = ( - cachedResolvedNextUpItems.keys + - processedFreshContentIds - ).toSet() + val transientContentIds = candidatesToResolve + .asSequence() + .map { candidate -> candidate.content.id } + .filterNot { contentId -> contentId in processedFreshContentIds } + .toList() + if ( + transientContentIds.isNotEmpty() && + nextUpResolutionRetryAttempt < MAX_NEXT_UP_RESOLUTION_RETRIES && + networkStatusUiState.condition == NetworkCondition.Online + ) { + val retryDelayMs = NEXT_UP_RESOLUTION_RETRY_BASE_DELAY_MS * + (1L shl nextUpResolutionRetryAttempt) + delay(retryDelayMs) + withContext(Dispatchers.Main) { + nextUpResolutionRetryAttempt += 1 + } } - saveContinueWatchingSnapshots( - profileId = activeProfileId, - source = effectiveWatchProgressSource, - cacheGeneration = cwCacheGeneration, - nextUpItemsBySeries = deferredResults, - visibleContinueWatchingEntries = visibleContinueWatchingEntries, - todayIsoDate = todayIsoDate, - seedLastWatchedMap = seedLastWatchedMap, - ) } } @@ -915,6 +982,8 @@ internal const val HomeNextUpInitialResolutionLimit = 32 private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L private const val OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS = 3L * 60L * 1000L private const val NEXT_UP_RESOLUTION_CONCURRENCY = 4 +private const val MAX_NEXT_UP_RESOLUTION_RETRIES = 3 +private const val NEXT_UP_RESOLUTION_RETRY_BASE_DELAY_MS = 1_500L private suspend fun reconcileVisibleSeriesPosterBadges( items: List, @@ -1027,12 +1096,16 @@ internal fun buildHomeNextUpSeedCandidates( } } .toList() - val watchedSeeds = watchedItems.filter { item -> - item.type.isSeriesTypeForContinueWatching() && - item.season != null && - item.episode != null && - item.season != 0 && - !isMalformedNextUpSeedContentId(item.id) + val watchedSeeds = if (isTraktProgressActive) { + emptyList() + } else { + watchedItems.filter { item -> + item.type.isSeriesTypeForContinueWatching() && + item.season != null && + item.episode != null && + item.season != 0 && + !isMalformedNextUpSeedContentId(item.id) + } } return WatchingState.latestCompletedBySeries( @@ -1072,16 +1145,103 @@ internal fun filterNextUpItemsByCurrentSeeds( item.nextUpSeedEpisodeNumber == currentSeed.second } +internal fun isHomeNextUpSeedSourceLoaded( + isTraktProgressActive: Boolean, + hasLoadedRemoteProgress: Boolean, + hasLoadedWatchedItems: Boolean, + hasLoadedRemoteWatchedItems: Boolean, +): Boolean = hasLoadedRemoteProgress && ( + isTraktProgressActive || (hasLoadedWatchedItems && hasLoadedRemoteWatchedItems) +) + +internal fun cachedNextUpHasAired( + cached: CachedNextUpItem, + nowEpochMs: Long = WatchProgressClock.nowEpochMs(), +): Boolean = + com.nuvio.app.features.watchprogress.parseReleaseDateToEpochMs(cached.released) + ?.let { releaseEpochMs -> nowEpochMs >= releaseEpochMs } + ?: cached.hasAired + +internal fun hasHomeNextUpSeedChangedFromCache( + currentSeason: Int, + currentEpisode: Int, + cachedSeason: Int?, + cachedEpisode: Int?, +): Boolean { + if (cachedSeason == null || cachedEpisode == null) return false + return currentSeason != cachedSeason || currentEpisode != cachedEpisode +} + +internal fun hasUsableHomeNextUpMetadata(item: ContinueWatchingItem): Boolean { + val hasResolvedTitle = item.title.isNotBlank() && + !item.title.equals(item.parentMetaId, ignoreCase = true) + val hasArtwork = listOf( + item.imageUrl, + item.poster, + item.background, + item.episodeThumbnail, + ).any { value -> !value.isNullOrBlank() } + return hasResolvedTitle && hasArtwork +} + +internal fun mergeHomeNextUpItemsWithCache( + resolvedItems: Map>, + cachedItems: Map>, + conclusivelyProcessedContentIds: Set, +): Map> { + val retainedCachedItems = cachedItems.filterKeys { contentId -> + contentId !in conclusivelyProcessedContentIds || contentId in resolvedItems + } + val resolvedItemsWithCacheFallback = resolvedItems.mapValues { (contentId, pair) -> + pair.first to pair.second.withFallbackMetadata(cachedItems[contentId]?.second) + } + return retainedCachedItems + resolvedItemsWithCacheFallback +} + +internal enum class HomeNextUpCandidateMetadataOutcome { + Ready, + Dismissed, + Transient, +} + +internal data class HomeNextUpCandidateMetadataDecision( + val item: ContinueWatchingItem, + val outcome: HomeNextUpCandidateMetadataOutcome, +) + +internal fun classifyHomeNextUpCandidateMetadata( + freshItem: ContinueWatchingItem, + cachedFallbackItem: ContinueWatchingItem?, + dismissedNextUpKeys: Set, +): HomeNextUpCandidateMetadataDecision { + val mergedItem = freshItem.withFallbackMetadata(cachedFallbackItem) + val dismissKey = nextUpDismissKey( + mergedItem.parentMetaId, + mergedItem.nextUpSeedSeasonNumber, + mergedItem.nextUpSeedEpisodeNumber, + ) + val outcome = when { + dismissKey in dismissedNextUpKeys -> HomeNextUpCandidateMetadataOutcome.Dismissed + hasUsableHomeNextUpMetadata(mergedItem) -> HomeNextUpCandidateMetadataOutcome.Ready + else -> HomeNextUpCandidateMetadataOutcome.Transient + } + return HomeNextUpCandidateMetadataDecision( + item = mergedItem, + outcome = outcome, + ) +} + private suspend fun resolveHomeNextUpCandidate( completedEntry: CompletedSeriesCandidate, watchProgressEntries: List, watchedItems: List, + cachedFallbackItem: ContinueWatchingItem?, todayIsoDate: String, preferFurthestEpisode: Boolean, showUnairedNextUp: Boolean, dismissedNextUpKeys: Set, isTraktProgressActive: Boolean, -): Pair>? { +): HomeNextUpResolutionAttempt { val contentId = completedEntry.content.id val meta = try { MetaDetailsRepository.fetch( @@ -1092,35 +1252,35 @@ private suspend fun resolveHomeNextUpCandidate( if (error is CancellationException) throw error null } - if (meta == null) return null + if (meta == null) { + return HomeNextUpResolutionAttempt.transientFailure() + } val resolvedProgressEntries = if (isTraktProgressActive) { remapTraktProgressEntries(watchProgressEntries, contentId) } else { watchProgressEntries } - val resolvedWatchedItems = if (isTraktProgressActive) { - remapTraktWatchedItems(watchedItems, contentId) - } else { - watchedItems - } + val resolvedWatchedItems = 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 - } - }, - ) + if (!isTraktProgressActive) { + 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, @@ -1130,15 +1290,29 @@ private suspend fun resolveHomeNextUpCandidate( preferFurthestEpisode = preferFurthestEpisode, showUnairedNextUp = showUnairedNextUp, ) - if (action == null) return null - if (action.resumePositionMs != null) return null + if (action == null) { + return HomeNextUpResolutionAttempt.conclusiveNone() + } + if (action.resumePositionMs != null) { + return HomeNextUpResolutionAttempt.conclusiveNone() + } val nextEpisode = meta.videoForSeriesAction(action) - if (nextEpisode == null) return null - val item = completedEntry.toContinueWatchingSeed(meta) - .toUpNextContinueWatchingItem(nextEpisode) - if (nextUpDismissKey(item.parentMetaId, item.nextUpSeedSeasonNumber, item.nextUpSeedEpisodeNumber) in dismissedNextUpKeys) { - return null + if (nextEpisode == null) { + return HomeNextUpResolutionAttempt.conclusiveNone() + } + val metadataDecision = classifyHomeNextUpCandidateMetadata( + freshItem = completedEntry.toContinueWatchingSeed(meta) + .toUpNextContinueWatchingItem(nextEpisode), + cachedFallbackItem = cachedFallbackItem, + dismissedNextUpKeys = dismissedNextUpKeys, + ) + val item = metadataDecision.item + if (metadataDecision.outcome == HomeNextUpCandidateMetadataOutcome.Dismissed) { + return HomeNextUpResolutionAttempt.conclusiveNone() + } + if (metadataDecision.outcome == HomeNextUpCandidateMetadataOutcome.Transient) { + return HomeNextUpResolutionAttempt.transientFailure() } val sortTimestamp = if (item.isReleaseAlert) { @@ -1146,7 +1320,9 @@ private suspend fun resolveHomeNextUpCandidate( } else { completedEntry.markedAtEpochMs } - return contentId to (sortTimestamp to item) + return HomeNextUpResolutionAttempt.success( + contentId to (sortTimestamp to item), + ) } private fun MetaDetails.videoForSeriesAction(action: SeriesPrimaryAction): MetaVideo? { @@ -1188,7 +1364,7 @@ private fun shouldTreatAsActiveInProgressForNextUpSuppression( internal fun buildHomeContinueWatchingItems( visibleEntries: List, - cachedInProgressByVideoId: Map = emptyMap(), + cachedInProgressByProgressKey: Map = emptyMap(), nextUpItemsBySeries: Map>, nextUpSuppressedSeriesIds: Set? = null, sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT, @@ -1210,7 +1386,7 @@ internal fun buildHomeContinueWatchingItems( HomeContinueWatchingCandidate( lastUpdatedEpochMs = entry.lastUpdatedEpochMs, item = liveItem - .withFallbackMetadata(cachedInProgressByVideoId[entry.videoId]) + .withFallbackMetadata(cachedInProgressByProgressKey[entry.resolvedProgressKey()]) .withCloudLibraryMetadata(cloudLibraryUiState), isProgressEntry = true, ) @@ -1300,9 +1476,36 @@ private data class HomeContinueWatchingCandidate( private data class HomeNextUpCandidateResolution( val candidate: CompletedSeriesCandidate, - val resolved: Pair>?, + val attempt: HomeNextUpResolutionAttempt, ) +private data class HomeNextUpResolutionAttempt( + val resolved: Pair>?, + val isConclusive: Boolean, +) { + companion object { + fun success( + resolved: Pair>, + ): HomeNextUpResolutionAttempt = + HomeNextUpResolutionAttempt( + resolved = resolved, + isConclusive = true, + ) + + fun conclusiveNone(): HomeNextUpResolutionAttempt = + HomeNextUpResolutionAttempt( + resolved = null, + isConclusive = true, + ) + + fun transientFailure(): HomeNextUpResolutionAttempt = + HomeNextUpResolutionAttempt( + resolved = null, + isConclusive = false, + ) + } +} + private fun saveContinueWatchingSnapshots( profileId: Int, source: WatchProgressSource, @@ -1339,26 +1542,13 @@ private fun saveContinueWatchingSnapshots( isNewSeasonRelease = item.isNewSeasonRelease, ) } - val inProgressCache = visibleContinueWatchingEntries.map { entry -> - CachedInProgressItem( - contentId = entry.parentMetaId, - contentType = entry.contentType, - name = entry.title, - poster = entry.poster, - backdrop = entry.background, - logo = entry.logo, - videoId = entry.videoId, - season = entry.seasonNumber, - episode = entry.episodeNumber, - episodeTitle = entry.episodeTitle, - episodeThumbnail = entry.episodeThumbnail, - pauseDescription = entry.pauseDescription, - position = entry.lastPositionMs, - duration = entry.durationMs, - lastWatched = entry.lastUpdatedEpochMs, - progressPercent = entry.progressPercent, - ) - } + val inProgressCache = buildHomeInProgressCacheSnapshot( + visibleEntries = visibleContinueWatchingEntries, + cachedEntries = ContinueWatchingEnrichmentCache.getInProgressSnapshot( + profileId = profileId, + source = source, + ), + ) ContinueWatchingEnrichmentCache.saveSnapshots( profileId = profileId, source = source, @@ -1368,6 +1558,39 @@ private fun saveContinueWatchingSnapshots( ) } +internal fun buildHomeInProgressCacheSnapshot( + visibleEntries: List, + cachedEntries: List, +): List { + val cachedByProgressKey = cachedEntries.associateBy(CachedInProgressItem::resolvedProgressKey) + return visibleEntries.map { entry -> + val item = entry + .toContinueWatchingItem() + .withFallbackMetadata( + cachedByProgressKey[entry.resolvedProgressKey()]?.toContinueWatchingItem(), + ) + CachedInProgressItem( + contentId = entry.parentMetaId, + contentType = entry.contentType, + name = item.title, + poster = item.poster, + backdrop = item.background, + logo = item.logo, + videoId = entry.videoId, + season = entry.seasonNumber, + episode = entry.episodeNumber, + episodeTitle = item.episodeTitle, + episodeThumbnail = item.episodeThumbnail, + pauseDescription = item.pauseDescription, + position = entry.lastPositionMs, + duration = entry.durationMs, + lastWatched = entry.lastUpdatedEpochMs, + progressPercent = entry.progressPercent, + progressKey = entry.resolvedProgressKey(), + ) + } +} + internal fun effectiveContinueWatchingCacheSource( isTraktProgressActive: Boolean, ): WatchProgressSource = @@ -1405,6 +1628,9 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? { nextSeasonNumber = season, releasedIso = released, ) + val resolvedPoster = poster.nonBlankOrNull() + val resolvedBackdrop = backdrop.nonBlankOrNull() + val resolvedEpisodeThumbnail = episodeThumbnail.nonBlankOrNull() return ContinueWatchingItem( parentMetaId = contentId, parentMetaType = contentType, @@ -1415,16 +1641,16 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? { episodeNumber = episode, episodeTitle = episodeTitle, ), - imageUrl = episodeThumbnail ?: backdrop ?: poster, - logo = logo, - poster = poster, - background = backdrop, + imageUrl = resolvedEpisodeThumbnail ?: resolvedBackdrop ?: resolvedPoster, + logo = logo.nonBlankOrNull(), + poster = resolvedPoster, + background = resolvedBackdrop, seasonNumber = season, episodeNumber = episode, - episodeTitle = episodeTitle, - episodeThumbnail = episodeThumbnail, - pauseDescription = pauseDescription, - released = released, + episodeTitle = episodeTitle.nonBlankOrNull(), + episodeThumbnail = resolvedEpisodeThumbnail, + pauseDescription = pauseDescription.nonBlankOrNull(), + released = released.nonBlankOrNull(), isNextUp = true, nextUpSeedSeasonNumber = seedSeason, nextUpSeedEpisodeNumber = seedEpisode, @@ -1448,6 +1674,9 @@ private fun CachedInProgressItem.toContinueWatchingItem(): ContinueWatchingItem } else { 0f } + val resolvedPoster = poster.nonBlankOrNull() + val resolvedBackdrop = backdrop.nonBlankOrNull() + val resolvedEpisodeThumbnail = episodeThumbnail.nonBlankOrNull() return ContinueWatchingItem( parentMetaId = contentId, @@ -1459,15 +1688,15 @@ private fun CachedInProgressItem.toContinueWatchingItem(): ContinueWatchingItem episodeNumber = episode, episodeTitle = episodeTitle, ), - imageUrl = episodeThumbnail ?: backdrop ?: poster, - logo = logo, - poster = poster, - background = backdrop, + imageUrl = resolvedEpisodeThumbnail ?: resolvedBackdrop ?: resolvedPoster, + logo = logo.nonBlankOrNull(), + poster = resolvedPoster, + background = resolvedBackdrop, seasonNumber = season, episodeNumber = episode, - episodeTitle = episodeTitle, - episodeThumbnail = episodeThumbnail, - pauseDescription = pauseDescription, + episodeTitle = episodeTitle.nonBlankOrNull(), + episodeThumbnail = resolvedEpisodeThumbnail, + pauseDescription = pauseDescription.nonBlankOrNull(), isNextUp = false, nextUpSeedSeasonNumber = null, nextUpSeedEpisodeNumber = null, @@ -1481,29 +1710,35 @@ private fun CachedInProgressItem.toContinueWatchingItem(): ContinueWatchingItem private fun ContinueWatchingItem.withFallbackMetadata( fallback: ContinueWatchingItem?, ): ContinueWatchingItem { - if (fallback == null) return this - val fallbackTitle = fallback.title - .takeIf { it.isNotBlank() } - ?.takeUnless { fallback.hasPlaceholderCloudTitle() } + val nonBlankFallbackTitle = fallback?.title?.takeIf { it.isNotBlank() } + val fallbackHasPlaceholderTitle = fallback?.hasPlaceholderHomeTitle() == true + val fallbackTitle = nonBlankFallbackTitle + ?.takeUnless { fallbackHasPlaceholderTitle } return copy( title = when { - title.isBlank() -> fallback.title - hasPlaceholderCloudTitle() && fallbackTitle != null -> fallbackTitle + title.isBlank() && nonBlankFallbackTitle != null -> nonBlankFallbackTitle + hasPlaceholderHomeTitle() && fallbackTitle != null -> fallbackTitle else -> title }, - subtitle = subtitle.ifBlank { fallback.subtitle }, - imageUrl = imageUrl ?: fallback.imageUrl, - logo = logo ?: fallback.logo, - poster = poster ?: fallback.poster, - background = background ?: fallback.background, - episodeTitle = episodeTitle ?: fallback.episodeTitle, - episodeThumbnail = episodeThumbnail ?: fallback.episodeThumbnail, - pauseDescription = pauseDescription ?: fallback.pauseDescription, - released = released ?: fallback.released, + subtitle = subtitle.takeIf { it.isNotBlank() } + ?: fallback?.subtitle?.takeIf { it.isNotBlank() }.orEmpty(), + imageUrl = imageUrl.orNonBlank(fallback?.imageUrl), + logo = logo.orNonBlank(fallback?.logo), + poster = poster.orNonBlank(fallback?.poster), + background = background.orNonBlank(fallback?.background), + episodeTitle = episodeTitle.orNonBlank(fallback?.episodeTitle), + episodeThumbnail = episodeThumbnail.orNonBlank(fallback?.episodeThumbnail), + pauseDescription = pauseDescription.orNonBlank(fallback?.pauseDescription), + released = released.orNonBlank(fallback?.released), ) } +private fun String?.nonBlankOrNull(): String? = this?.takeIf { it.isNotBlank() } + +private fun String?.orNonBlank(fallback: String?): String? = + nonBlankOrNull() ?: fallback.nonBlankOrNull() + private fun ContinueWatchingItem.withCloudLibraryMetadata( cloudLibraryUiState: CloudLibraryUiState?, ): ContinueWatchingItem { @@ -1522,11 +1757,10 @@ private fun ContinueWatchingItem.withCloudLibraryMetadata( ) } -private fun ContinueWatchingItem.hasPlaceholderCloudTitle(): Boolean { - if (!isCloudLibraryContinueWatchingItem()) return false +private fun ContinueWatchingItem.hasPlaceholderHomeTitle(): Boolean { val normalizedTitle = title.trim() return normalizedTitle.equals(parentMetaId, ignoreCase = true) || - normalizedTitle.equals(videoId, ignoreCase = true) + (isCloudLibraryContinueWatchingItem() && normalizedTitle.equals(videoId, ignoreCase = true)) } private fun ContinueWatchingItem.isCloudLibraryContinueWatchingItem(): Boolean = @@ -1569,29 +1803,3 @@ private suspend fun remapTraktProgressEntries( } } } - -private suspend fun remapTraktWatchedItems( - items: List, - contentId: String, -): List { - return items.map { item -> - if (item.id != contentId) { - item - } else { - val mapping = TraktEpisodeMappingService.resolveAddonEpisodeMapping( - contentId = item.id, - contentType = item.type ?: "series", - season = item.season, - episode = item.episode, - ) - if (mapping != null) { - item.copy( - season = mapping.season, - episode = mapping.episode, - ) - } else { - item - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt index e9a87702..86b44aed 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt @@ -335,7 +335,12 @@ internal fun PlayerScreenRuntime.switchToDownloadedEpisode(downloadItem: Downloa fallbackVideoId = episode.id, ) val resolvedVideoId = episode.id.takeIf { it.isNotBlank() } ?: fallbackVideoId - val epEntry = WatchProgressRepository.progressForVideo(resolvedVideoId) + val epEntry = WatchProgressRepository.progressForVideo( + videoId = resolvedVideoId, + parentMetaId = parentMetaId, + seasonNumber = episode.season, + episodeNumber = episode.episode, + ) ?.takeIf { !it.isCompleted } val epResumeFraction = epEntry?.progressPercent ?.takeIf { it > 0f } @@ -439,7 +444,10 @@ private fun PlayerScreenRuntime.resolveEpisodeResume(epVideoId: String, episode: fallbackVideoId = epVideoId, ) val epEntry = WatchProgressRepository.progressForVideo( - epVideoId.takeIf { it.isNotBlank() } ?: epResumeVideoId, + videoId = epVideoId.takeIf { it.isNotBlank() } ?: epResumeVideoId, + parentMetaId = parentMetaId, + seasonNumber = episode.season, + episodeNumber = episode.episode, )?.takeIf { !it.isCompleted } val epResumeFraction = epEntry?.progressPercent ?.takeIf { it > 0f } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt index 717ca541..1c43312c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt @@ -466,7 +466,7 @@ private fun PlayerScreenRuntime.RenderPlayerModals(displayedPositionMs: Long) { parentMetaId = parentMetaId, activeSeasonNumber = activeSeasonNumber, activeEpisodeNumber = activeEpisodeNumber, - watchProgressByVideoId = watchProgressUiState.byVideoId, + watchProgressByVideoId = watchProgressUiState.byVideoIdForContent(parentMetaId), watchedKeys = watchedUiState.watchedKeys, blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes, episodeStreamsPanelState = episodeStreamsPanelState, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index 0d3e1677..fd9f2615 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -157,7 +157,12 @@ fun StreamsScreen( val storedProgress = if (startFromBeginning) { null } else { - watchProgressUiState.byVideoId[videoId] + watchProgressUiState.progressForVideo( + videoId = videoId, + parentMetaId = parentMetaId, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + ) } val storedProgressFraction = storedProgress ?.takeIf { it.isResumable } 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 9a3f7d6d..8da4ec19 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 @@ -17,6 +17,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -94,6 +95,7 @@ object TraktProgressRepository { private var refreshRequestId: Long = 0L private val refreshJobMutex = Mutex() private var inFlightRefresh: Deferred? = null + private var metadataHydrationJob: Job? = null private var remoteEntriesSnapshot: List = emptyList() private var refreshIntervalMs = REFRESH_BASE_INTERVAL_MS private var lastKnownMoviesWatchedAt: String? = null @@ -266,11 +268,13 @@ object TraktProgressRepository { private suspend fun hasActivityChanged(headers: Map): Boolean { val activities = runCatching { + val endpoint = "$BASE_URL/sync/last_activities" + val payload = httpGetTextWithHeaders( + url = endpoint, + headers = headers, + ) json.decodeFromString( - httpGetTextWithHeaders( - url = "$BASE_URL/sync/last_activities", - headers = headers, - ), + payload, ) }.onFailure { error -> if (error is CancellationException) throw error @@ -416,7 +420,8 @@ object TraktProgressRepository { requestId: Long, entries: List, ) { - scope.launch { + metadataHydrationJob?.cancel() + metadataHydrationJob = scope.launch { runCatching { hydrateEntriesFromAddonMeta( requestId = requestId, @@ -435,8 +440,9 @@ object TraktProgressRepository { errorMessage: String? = _uiState.value.errorMessage, hasLoadedRemoteProgress: Boolean = _uiState.value.hasLoadedRemoteProgress, ) { + val publishedEntries = mergeWithActiveOptimistic(entries) _uiState.value = TraktProgressUiState( - entries = mergeWithActiveOptimistic(entries), + entries = publishedEntries, isLoading = isLoading, errorMessage = errorMessage, hasLoadedRemoteProgress = hasLoadedRemoteProgress, @@ -549,14 +555,13 @@ object TraktProgressRepository { if (!TraktAuthRepository.isAuthenticated.value) return val normalizedContentId = contentId.trim() if (normalizedContentId.isBlank()) return + if (!hasCompleteTraktEpisodeContext(seasonNumber, episodeNumber)) return val shouldRemove: (WatchProgressEntry) -> Boolean = { entry -> - if (entry.parentMetaId != normalizedContentId) { - false - } else if (seasonNumber != null && episodeNumber != null) { - entry.seasonNumber == seasonNumber && entry.episodeNumber == episodeNumber - } else { - true - } + entry.matchesTraktRemovalContext( + contentId = normalizedContentId, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + ) } removeOptimisticProgress(shouldRemove) remoteEntriesSnapshot = remoteEntriesSnapshot.filterNot(shouldRemove) @@ -577,6 +582,7 @@ object TraktProgressRepository { ) { val normalizedContentId = contentId.trim() if (normalizedContentId.isBlank()) return + if (!hasCompleteTraktEpisodeContext(seasonNumber, episodeNumber)) return val headers = TraktAuthRepository.authorizedHeaders() ?: return applyOptimisticRemoval( @@ -654,9 +660,10 @@ object TraktProgressRepository { var page = 1 val limit = 1000 while (true) { + val endpoint = "$BASE_URL/users/hidden/dropped?type=show&page=$page&limit=$limit" val payload = runCatching { httpGetTextWithHeaders( - url = "$BASE_URL/users/hidden/dropped?type=show&page=$page&limit=$limit", + url = endpoint, headers = headers, ) }.getOrNull() ?: break @@ -679,16 +686,18 @@ object TraktProgressRepository { } private suspend fun fetchPlaybackEntries(headers: Map): List = withContext(Dispatchers.Default) { + val moviesEndpoint = "$BASE_URL/sync/playback/movies" + val episodesEndpoint = "$BASE_URL/sync/playback/episodes" val payloads = coroutineScope { val moviesPayload = async { httpGetTextWithHeaders( - url = "$BASE_URL/sync/playback/movies", + url = moviesEndpoint, headers = headers, ) } val episodesPayload = async { httpGetTextWithHeaders( - url = "$BASE_URL/sync/playback/episodes", + url = episodesEndpoint, headers = headers, ) } @@ -784,10 +793,11 @@ object TraktProgressRepository { cutoffMs?.let { append("&start_at=").append(epochMsToTraktIso(it)) } } val payload = httpGetTextWithHeaders(url = url, headers = headers) - return json.decodeFromString>(payload) + val mapped = json.decodeFromString>(payload) .mapIndexedNotNull { index, item -> mapHistoryMovie(item = item, fallbackIndex = index) } .filter { entry -> cutoffMs == null || entry.lastUpdatedEpochMs >= cutoffMs } .distinctBy { entry -> entry.videoId } + return mapped } private suspend fun fetchWatchedShowSeedEntries( @@ -1152,6 +1162,8 @@ object TraktProgressRepository { refreshRequestId += 1L inFlightRefresh?.cancel() inFlightRefresh = null + metadataHydrationJob?.cancel() + metadataHydrationJob = null } private fun isLatestRefreshRequest(requestId: Long): Boolean = refreshRequestId == requestId @@ -1173,7 +1185,10 @@ object TraktProgressRepository { launch { val (metaType, metaId) = key val meta = semaphore.withPermit { - fetchAddonMetaForHydration(metaType = metaType, metaId = metaId) + fetchAddonMetaForHydration( + metaType = metaType, + metaId = metaId, + ) } results.send( TraktMetadataHydrationResult( @@ -1186,9 +1201,14 @@ object TraktProgressRepository { repeat(entriesByContent.size) { val result = results.receive() - if (!isLatestRefreshRequest(requestId)) return@coroutineScope + if (!isLatestRefreshRequest(requestId)) { + throw CancellationException("Superseded Trakt metadata hydration request $requestId") + } val meta = result.meta ?: return@repeat - val hydrated = hydrateEntriesWithAddonMeta(entries = result.entries, meta = meta) + val hydrated = hydrateEntriesWithAddonMeta( + entries = result.entries, + meta = meta, + ) if (hydrated.isEmpty()) return@repeat val hydratedByVideoId = hydrated.associateBy { entry -> entry.videoId } @@ -1293,16 +1313,16 @@ object TraktProgressRepository { entry.copy( title = entry.title.takeIf { it.isNotBlank() } ?: meta.name, - logo = entry.logo ?: meta.logo, - poster = entry.poster ?: meta.poster, - background = entry.background ?: meta.background, + logo = entry.logo?.takeIf(String::isNotBlank) ?: meta.logo, + poster = entry.poster?.takeIf(String::isNotBlank) ?: meta.poster, + background = entry.background?.takeIf(String::isNotBlank) ?: meta.background, seasonNumber = if (entry.isCompleted) entry.seasonNumber else (resolvedSeason ?: entry.seasonNumber), episodeNumber = if (entry.isCompleted) entry.episodeNumber else (resolvedEpisode ?: entry.episodeNumber), - episodeTitle = entry.episodeTitle ?: episode?.title, - episodeThumbnail = entry.episodeThumbnail ?: episode?.thumbnail, - pauseDescription = entry.pauseDescription - ?: episode?.overview - ?: meta.description, + episodeTitle = entry.episodeTitle?.takeIf(String::isNotBlank) ?: episode?.title, + episodeThumbnail = entry.episodeThumbnail?.takeIf(String::isNotBlank) ?: episode?.thumbnail, + pauseDescription = entry.pauseDescription?.takeIf(String::isNotBlank) + ?: episode?.overview?.takeIf(String::isNotBlank) + ?: meta.description?.takeIf(String::isNotBlank), ) } @@ -1558,6 +1578,25 @@ object TraktProgressRepository { } } +internal fun hasCompleteTraktEpisodeContext( + seasonNumber: Int?, + episodeNumber: Int?, +): Boolean = (seasonNumber == null) == (episodeNumber == null) + +internal fun WatchProgressEntry.matchesTraktRemovalContext( + contentId: String, + seasonNumber: Int? = null, + episodeNumber: Int? = null, +): Boolean { + val normalizedContentId = contentId.trim() + if (normalizedContentId.isBlank()) return false + if (!hasCompleteTraktEpisodeContext(seasonNumber, episodeNumber)) return false + if (parentMetaId != normalizedContentId) return false + return seasonNumber == null || ( + this.seasonNumber == seasonNumber && this.episodeNumber == episodeNumber + ) +} + @Serializable private data class TraktPlaybackItem( @SerialName("id") val id: Long? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt index 3f778d81..4aae9a59 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt @@ -22,6 +22,7 @@ data class WatchedUiState( val items: List = emptyList(), val watchedKeys: Set = emptySet(), val isLoaded: Boolean = false, + val hasLoadedRemoteItems: Boolean = false, ) fun MetaPreview.toWatchedItem(markedAtEpochMs: Long): WatchedItem = 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 aa25da84..27ee2076 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 @@ -1,6 +1,8 @@ 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.features.details.MetaDetails import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.profiles.ProfileRepository @@ -35,6 +37,7 @@ private data class StoredWatchedPayload( val lastSuccessfulPushEpochMs: Long = 0L, val deltaCursorEventId: Long = 0L, val deltaInitialized: Boolean = false, + val dirtyWatchedKeys: Set = emptySet(), ) internal enum class WatchedTraktHistorySync { @@ -121,6 +124,9 @@ object WatchedRepository { private var traktFullyWatchedSeriesKeys: Set = emptySet() private var nuvioHasLoaded: Boolean = false private var traktHasLoaded: Boolean = false + private var nuvioHasLoadedRemote: Boolean = false + private var traktHasLoadedRemote: Boolean = false + private var nuvioDirtyWatchedKeys: MutableSet = mutableSetOf() private var lastSuccessfulPushEpochMs: Long = 0L private var deltaCursorEventId: Long = 0L private var deltaInitialized: Boolean = false @@ -165,6 +171,9 @@ object WatchedRepository { traktFullyWatchedSeriesKeys = emptySet() nuvioHasLoaded = false traktHasLoaded = false + nuvioHasLoadedRemote = false + traktHasLoadedRemote = false + nuvioDirtyWatchedKeys.clear() lastSuccessfulPushEpochMs = 0L deltaCursorEventId = 0L deltaInitialized = false @@ -184,6 +193,9 @@ object WatchedRepository { traktFullyWatchedSeriesKeys = emptySet() nuvioHasLoaded = true traktHasLoaded = false + nuvioHasLoadedRemote = false + traktHasLoadedRemote = false + nuvioDirtyWatchedKeys.clear() val payload = WatchedStorage.loadPayload(profileId).orEmpty().trim() if (payload.isNotEmpty()) { @@ -197,11 +209,14 @@ object WatchedRepository { .map(WatchedItem::normalizedMarkedAt) .associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) } .toMutableMap() + nuvioDirtyWatchedKeys = storedPayload.dirtyWatchedKeys + .filterTo(mutableSetOf()) { key -> key in nuvioItemsByKey } nuvioFullyWatchedSeriesKeys = storedPayload.fullyWatchedSeriesKeys } else { lastSuccessfulPushEpochMs = 0L deltaCursorEventId = 0L deltaInitialized = false + nuvioDirtyWatchedKeys.clear() nuvioFullyWatchedSeriesKeys = emptySet() } @@ -221,6 +236,9 @@ object WatchedRepository { traktItemsByKey.clear() traktFullyWatchedSeriesKeys = emptySet() traktHasLoaded = false + traktHasLoadedRemote = false + } else { + nuvioHasLoadedRemote = false } activeSource = source sourceGeneration += 1L @@ -294,28 +312,33 @@ object WatchedRepository { val effectiveSource = activateEffectiveSource(source) val operation = newRefreshOperation(profileId) ?: return false - val pullStartedEpochMs = WatchedClock.nowEpochMs() + if (effectiveSource == WatchProgressSource.NUVIO_SYNC) { + val authState = AuthRepository.state.value + if (authState !is AuthState.Authenticated || authState.isAnonymous) { + // Local watched state is authoritative when this account has no Nuvio upstream. + nuvioHasLoaded = true + nuvioHasLoadedRemote = true + publish() + return true + } + } return try { if (effectiveSource == WatchProgressSource.TRAKT) { pullSnapshotFromAdapter( adapter = traktSyncAdapter, operation = operation, profileId = profileId, - lastPushEpochMs = 0L, - pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = true, ) } else if (forceSnapshot) { refreshNuvioSnapshot( operation = operation, profileId = profileId, - pullStartedEpochMs = pullStartedEpochMs, ) } else { pullSupabaseDeltaFromServer( operation = operation, profileId = profileId, - pullStartedEpochMs = pullStartedEpochMs, ) } } catch (error: CancellationException) { @@ -329,7 +352,6 @@ object WatchedRepository { private suspend fun refreshNuvioSnapshot( operation: WatchedRefreshOperation, profileId: Int, - pullStartedEpochMs: Long, ): Boolean { val cursorBeforeSnapshot = try { syncAdapter.getDeltaCursor(profileId) @@ -344,8 +366,6 @@ object WatchedRepository { adapter = syncAdapter, operation = operation, profileId = profileId, - lastPushEpochMs = lastSuccessfulPushEpochMs, - pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = cursorBeforeSnapshot == null, ) if (!applied || !isActiveOperation(operation)) return false @@ -361,8 +381,6 @@ object WatchedRepository { adapter: WatchedSyncAdapter, operation: WatchedRefreshOperation, profileId: Int, - lastPushEpochMs: Long, - pullStartedEpochMs: Long, resetDeltaState: Boolean, ): Boolean { val serverItems = adapter.pull( @@ -372,22 +390,26 @@ object WatchedRepository { if (!isActiveOperation(operation)) return false val localAtApply = itemsForSource(operation.sourceOperation.source).values.toList() - val mergedItems = mergeWatchedItemsPreservingUnsynced( + val mergedSnapshot = mergeWatchedSnapshot( serverItems = serverItems, localItems = localAtApply, - lastSuccessfulPushEpochMs = lastPushEpochMs, - pullStartedEpochMs = pullStartedEpochMs, - preserveWhenNoSuccessfulPush = operation.sourceOperation.source == WatchProgressSource.NUVIO_SYNC, - ).toMutableMap() + dirtyKeys = if (operation.sourceOperation.source == WatchProgressSource.NUVIO_SYNC) { + nuvioDirtyWatchedKeys + } else { + emptySet() + }, + ) replaceWatchedItemsForSource( source = operation.sourceOperation.source, nuvioItems = nuvioItemsByKey, traktItems = traktItemsByKey, - replacement = mergedItems, + replacement = mergedSnapshot.items, ) when (operation.sourceOperation.source) { WatchProgressSource.NUVIO_SYNC -> { + nuvioDirtyWatchedKeys = mergedSnapshot.dirtyKeys.toMutableSet() nuvioHasLoaded = true + nuvioHasLoadedRemote = true if (resetDeltaState) { deltaCursorEventId = 0L deltaInitialized = false @@ -395,6 +417,7 @@ object WatchedRepository { } WatchProgressSource.TRAKT -> { traktHasLoaded = true + traktHasLoadedRemote = true } } publish() @@ -407,18 +430,29 @@ object WatchedRepository { private suspend fun pullSupabaseDeltaFromServer( operation: WatchedRefreshOperation, profileId: Int, - pullStartedEpochMs: Long, ): Boolean { if (!isActiveOperation(operation)) return false if (!deltaInitialized) { - val cursorBeforeSnapshot = syncAdapter.getDeltaCursor(profileId) ?: return false + val cursorBeforeSnapshot = try { + syncAdapter.getDeltaCursor(profileId) + } catch (error: CancellationException) { + throw error + } catch (_: Throwable) { + null + } if (!isActiveOperation(operation)) return false + if (cursorBeforeSnapshot == null) { + return pullSnapshotFromAdapter( + adapter = syncAdapter, + operation = operation, + profileId = profileId, + resetDeltaState = true, + ) + } val applied = pullSnapshotFromAdapter( adapter = syncAdapter, operation = operation, profileId = profileId, - lastPushEpochMs = lastSuccessfulPushEpochMs, - pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = false, ) if (!applied || !isActiveOperation(operation)) return false @@ -442,8 +476,8 @@ object WatchedRepository { applyWatchedDeltaEvents( targetItems = nuvioItemsByKey, + dirtyKeys = nuvioDirtyWatchedKeys, events = events, - pullStartedEpochMs = pullStartedEpochMs, ) cursor = maxOf(cursor, events.maxOf { it.eventId }) deltaCursorEventId = cursor @@ -455,8 +489,12 @@ object WatchedRepository { if (!isActiveOperation(operation)) return false nuvioHasLoaded = true - if (changed) { + val remoteReadinessChanged = !nuvioHasLoadedRemote + nuvioHasLoadedRemote = true + if (changed || remoteReadinessChanged) { publish() + } + if (changed) { persistNuvio() } return true @@ -464,14 +502,15 @@ object WatchedRepository { private fun applyWatchedDeltaEvents( targetItems: MutableMap, + dirtyKeys: MutableSet, events: Collection, - pullStartedEpochMs: Long, ) { var upsertCount = 0 var deleteCount = 0 var removedCount = 0 var removedByFallbackKeyCount = 0 - var preservedDuringPullCount = 0 + var preservedDirtyCount = 0 + var acknowledgedDirtyCount = 0 var ignoredCount = 0 events.forEach { event -> @@ -479,7 +518,7 @@ object WatchedRepository { when (event.operation.lowercase()) { watchedDeltaOperationUpsert -> { upsertCount += 1 - targetItems[key] = WatchedItem( + val remoteItem = WatchedItem( id = event.contentId, type = event.contentType, name = event.title, @@ -487,28 +526,46 @@ object WatchedRepository { episode = event.episode, markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(event.watchedAt), ) + val localItem = targetItems[key]?.normalizedMarkedAt() + if ( + key in dirtyKeys && + localItem != null && + remoteItem.markedAtEpochMs < localItem.markedAtEpochMs + ) { + preservedDirtyCount += 1 + } else { + targetItems[key] = remoteItem + if (dirtyKeys.remove(key)) { + acknowledgedDirtyCount += 1 + } + } } watchedDeltaOperationDelete -> { deleteCount += 1 - val localItem = targetItems[key] - if (localItem != null && wasWatchedItemMarkedDuringPull(localItem, pullStartedEpochMs)) { - preservedDuringPullCount += 1 - return@forEach - } - val removedItem = targetItems.remove(key) - if (removedItem != null) { - removedCount += 1 - } else if ( - removeWatchedItemByStableDeleteKey( + val matchingKey = if (key in targetItems) { + key + } else { + findWatchedItemStableDeleteKey( targetItems = targetItems, contentId = event.contentId, contentType = event.contentType, season = event.season, episode = event.episode, ) - ) { + } + if (matchingKey == null) { + return@forEach + } + if (matchingKey in dirtyKeys) { + preservedDirtyCount += 1 + return@forEach + } + val removedItem = targetItems.remove(matchingKey) + if (removedItem != null) { removedCount += 1 - removedByFallbackKeyCount += 1 + if (matchingKey != key) { + removedByFallbackKeyCount += 1 + } } } else -> { @@ -520,31 +577,23 @@ object WatchedRepository { log.i { "Applied watched delta events total=${events.size} upserts=$upsertCount deletes=$deleteCount " + "removed=$removedCount removedByFallbackKey=$removedByFallbackKeyCount " + - "preservedDuringPull=$preservedDuringPullCount ignored=$ignoredCount" + "preservedDirty=$preservedDirtyCount acknowledgedDirty=$acknowledgedDirtyCount " + + "ignored=$ignoredCount" } } - private fun removeWatchedItemByStableDeleteKey( - targetItems: MutableMap, + private fun findWatchedItemStableDeleteKey( + targetItems: Map, contentId: String, contentType: String, season: Int?, episode: Int?, - ): Boolean { - val fallbackKey = targetItems.entries.firstOrNull { (_, item) -> - item.id == contentId && - watchedDeleteTypesCompatible(remoteType = contentType, localType = item.type) && - item.season == season && - item.episode == episode - }?.key ?: return false - - targetItems.remove(fallbackKey) - log.w { - "Removed watched delta delete with fallback key contentId=$contentId contentType=$contentType " + - "season=$season episode=$episode matchedKey=$fallbackKey" - } - return true - } + ): String? = targetItems.entries.firstOrNull { (_, item) -> + item.id == contentId && + watchedDeleteTypesCompatible(remoteType = contentType, localType = item.type) && + item.season == season && + item.episode == episode + }?.key private fun watchedDeleteTypesCompatible(remoteType: String, localType: String): Boolean { if (remoteType.equals(localType, ignoreCase = true)) return true @@ -619,6 +668,9 @@ object WatchedRepository { timestampedItems.forEach { watchedItem -> val key = watchedItemKey(watchedItem.type, watchedItem.id, watchedItem.season, watchedItem.episode) targetItems[key] = watchedItem + if (source == WatchProgressSource.NUVIO_SYNC) { + nuvioDirtyWatchedKeys += key + } } publish() if (shouldPersistWatchedSource(source)) { @@ -663,7 +715,12 @@ object WatchedRepository { val source = activeSource val targetItems = itemsForSource(source) val removedItems = items.mapNotNull { watchedItem -> - targetItems.remove(watchedItemKey(watchedItem.type, watchedItem.id, watchedItem.season, watchedItem.episode)) + val key = watchedItemKey(watchedItem.type, watchedItem.id, watchedItem.season, watchedItem.episode) + targetItems.remove(key)?.also { + if (source == WatchProgressSource.NUVIO_SYNC) { + nuvioDirtyWatchedKeys -= key + } + } } if (removedItems.isNotEmpty()) { publish() @@ -825,6 +882,10 @@ object WatchedRepository { watchedItemKey(it.type, it.id, it.season, it.episode) }, isLoaded = hasLoadedSource(activeSource), + hasLoadedRemoteItems = when (activeSource) { + WatchProgressSource.NUVIO_SYNC -> nuvioHasLoadedRemote + WatchProgressSource.TRAKT -> traktHasLoadedRemote + }, ) } @@ -840,6 +901,7 @@ object WatchedRepository { lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, deltaCursorEventId = deltaCursorEventId, deltaInitialized = deltaInitialized, + dirtyWatchedKeys = nuvioDirtyWatchedKeys.toSet(), ), ), ) @@ -851,13 +913,25 @@ object WatchedRepository { items: Collection, ) { if (profileId != currentProfileId || operationGeneration != profileGeneration) return + val acknowledgedDirtyKeys = acknowledgeSuccessfulWatchedPush( + currentItems = nuvioItemsByKey, + dirtyKeys = nuvioDirtyWatchedKeys, + pushedItems = items, + ) val latestPushed = items .asSequence() .map { item -> normalizeWatchedMarkedAtEpochMs(item.markedAtEpochMs) } .maxOrNull() ?: return - if (latestPushed <= lastSuccessfulPushEpochMs) return - lastSuccessfulPushEpochMs = latestPushed + val updatedLastSuccessfulPushEpochMs = maxOf(lastSuccessfulPushEpochMs, latestPushed) + if ( + acknowledgedDirtyKeys == nuvioDirtyWatchedKeys && + updatedLastSuccessfulPushEpochMs == lastSuccessfulPushEpochMs + ) { + return + } + nuvioDirtyWatchedKeys = acknowledgedDirtyKeys.toMutableSet() + lastSuccessfulPushEpochMs = updatedLastSuccessfulPushEpochMs persistNuvio() } @@ -907,58 +981,63 @@ object WatchedRepository { } } -internal fun mergeWatchedItemsPreservingUnsynced( +internal data class WatchedSnapshotMerge( + val items: Map, + val dirtyKeys: Set, +) + +internal fun mergeWatchedSnapshot( serverItems: Collection, localItems: Collection, - lastSuccessfulPushEpochMs: Long, - pullStartedEpochMs: Long, - preserveWhenNoSuccessfulPush: Boolean = true, -): Map { - val merged = serverItems + dirtyKeys: Set, +): WatchedSnapshotMerge { + val remoteByKey = serverItems .map(WatchedItem::normalizedMarkedAt) .associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) } .toMutableMap() - - localItems + val localByKey = localItems .map(WatchedItem::normalizedMarkedAt) - .forEach { localItem -> - val key = watchedItemKey(localItem.type, localItem.id, localItem.season, localItem.episode) - if (key in merged) return@forEach - if ( - shouldPreserveLocalWatchedItem( - localItem = localItem, - lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, - pullStartedEpochMs = pullStartedEpochMs, - preserveWhenNoSuccessfulPush = preserveWhenNoSuccessfulPush, - ) - ) { - merged[key] = localItem + .associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) } + val remainingDirtyKeys = dirtyKeys + .filterTo(mutableSetOf()) { key -> key in localByKey } + + remainingDirtyKeys.toList().forEach { key -> + val localItem = localByKey.getValue(key) + val remoteItem = remoteByKey[key] + if (remoteItem == null || remoteItem.markedAtEpochMs < localItem.markedAtEpochMs) { + remoteByKey[key] = localItem + } else { + remainingDirtyKeys -= key + } + } + + return WatchedSnapshotMerge( + items = remoteByKey, + dirtyKeys = remainingDirtyKeys, + ) +} + +internal fun acknowledgeSuccessfulWatchedPush( + currentItems: Map, + dirtyKeys: Set, + pushedItems: Collection, +): Set { + val remainingDirtyKeys = dirtyKeys.toMutableSet() + pushedItems + .map(WatchedItem::normalizedMarkedAt) + .forEach { pushedItem -> + val key = watchedItemKey( + type = pushedItem.type, + id = pushedItem.id, + season = pushedItem.season, + episode = pushedItem.episode, + ) + val currentItem = currentItems[key]?.normalizedMarkedAt() + if (currentItem == null || currentItem.markedAtEpochMs <= pushedItem.markedAtEpochMs) { + remainingDirtyKeys -= key } } - - return merged -} - -internal fun shouldPreserveLocalWatchedItem( - localItem: WatchedItem, - lastSuccessfulPushEpochMs: Long, - pullStartedEpochMs: Long, - preserveWhenNoSuccessfulPush: Boolean = true, -): Boolean { - val markedAt = normalizeWatchedMarkedAtEpochMs(localItem.markedAtEpochMs) - val wasMarkedAfterLastPush = - (preserveWhenNoSuccessfulPush && lastSuccessfulPushEpochMs <= 0L) || - (lastSuccessfulPushEpochMs > 0L && markedAt > lastSuccessfulPushEpochMs) - val wasMarkedDuringPull = pullStartedEpochMs > 0L && markedAt >= pullStartedEpochMs - return wasMarkedAfterLastPush || wasMarkedDuringPull -} - -internal fun wasWatchedItemMarkedDuringPull( - localItem: WatchedItem, - pullStartedEpochMs: Long, -): Boolean { - val markedAt = normalizeWatchedMarkedAtEpochMs(localItem.markedAtEpochMs) - return pullStartedEpochMs > 0L && markedAt >= pullStartedEpochMs + return remainingDirtyKeys } internal fun shouldUseTraktWatchedSync( 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 ae2e2b91..6977e02c 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 @@ -66,7 +66,8 @@ object WatchingActions { if (isCurrentlyWatched) { WatchedRepository.unmarkWatched(seriesItems) WatchProgressRepository.clearProgress( - releasedMainEpisodes.map(meta::episodePlaybackId), + videoIds = releasedMainEpisodes.map(meta::episodePlaybackId), + parentMetaId = meta.id, ) WatchedRepository.updateFullyWatchedSeries( id = meta.id, @@ -81,7 +82,8 @@ object WatchingActions { isFullyWatched = true, ) WatchProgressRepository.clearProgress( - releasedMainEpisodes.map(meta::episodePlaybackId), + videoIds = releasedMainEpisodes.map(meta::episodePlaybackId), + parentMetaId = meta.id, ) } } @@ -94,10 +96,16 @@ object WatchingActions { val watchedItem = meta.toEpisodeWatchedItem(episode) if (isCurrentlyWatched) { WatchedRepository.unmarkWatched(watchedItem) - WatchProgressRepository.clearProgress(meta.episodePlaybackId(episode)) + WatchProgressRepository.clearProgress( + videoId = meta.episodePlaybackId(episode), + parentMetaId = meta.id, + ) } else { WatchedRepository.markWatched(watchedItem) - WatchProgressRepository.clearProgress(meta.episodePlaybackId(episode)) + WatchProgressRepository.clearProgress( + videoId = meta.episodePlaybackId(episode), + parentMetaId = meta.id, + ) } reconcileSeriesWatchedState(meta) } @@ -136,7 +144,12 @@ object WatchingActions { meta = meta, todayIsoDate = todayIsoDate, isEpisodeCompleted = { episode -> - WatchProgressRepository.progressForVideo(meta.episodePlaybackId(episode))?.isCompleted == true + WatchProgressRepository.progressForVideo( + videoId = meta.episodePlaybackId(episode), + parentMetaId = meta.id, + seasonNumber = episode.season, + episodeNumber = episode.episode, + )?.isCompleted == true }, ) } @@ -177,10 +190,16 @@ object WatchingActions { val watchedItems = episodes.map(meta::toEpisodeWatchedItem) if (areCurrentlyWatched) { WatchedRepository.unmarkWatched(watchedItems) - WatchProgressRepository.clearProgress(episodes.map(meta::episodePlaybackId)) + WatchProgressRepository.clearProgress( + videoIds = episodes.map(meta::episodePlaybackId), + parentMetaId = meta.id, + ) } else { WatchedRepository.markWatched(watchedItems) - WatchProgressRepository.clearProgress(episodes.map(meta::episodePlaybackId)) + WatchProgressRepository.clearProgress( + videoIds = episodes.map(meta::episodePlaybackId), + parentMetaId = meta.id, + ) } reconcileSeriesWatchedState(meta) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt index 36b7ba1d..3e1671c6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt @@ -6,25 +6,56 @@ import com.nuvio.app.core.i18n.localizedUpNextLabel const val DefaultContinueWatchingLimit = 20 +private val watchingProgressRecencyComparator = + compareBy { record -> record.lastUpdatedEpochMs } + .thenBy { record -> record.seasonNumber ?: 0 } + .thenBy { record -> record.episodeNumber ?: 0 } + .thenBy(WatchingProgressRecord::lastPositionMs) + .thenBy(WatchingProgressRecord::isCompleted) + .thenBy(WatchingProgressRecord::identityKey) + .thenBy(WatchingProgressRecord::videoId) + +internal fun String?.isSeriesLikeWatchingContentType(): Boolean { + val type = this?.trim() ?: return false + return type.equals("series", ignoreCase = true) || + type.equals("tv", ignoreCase = true) || + type.equals("show", ignoreCase = true) || + type.equals("tvshow", ignoreCase = true) +} + +private fun WatchingContentRef.matchesWatchingContent(content: WatchingContentRef): Boolean { + val bothSeriesLike = type.isSeriesLikeWatchingContentType() && + content.type.isSeriesLikeWatchingContentType() + return if (bothSeriesLike) { + id.trim() == content.id.trim() + } else { + this == content + } +} + fun resumeProgressForSeries( content: WatchingContentRef, progressRecords: List, ): WatchingProgressRecord? = progressRecords - .filter { record -> record.content == content && !record.isCompleted } - .maxByOrNull { record -> record.lastUpdatedEpochMs } + .filter { record -> record.content.matchesWatchingContent(content) } + .maxWithOrNull(watchingProgressRecencyComparator) + ?.takeUnless { record -> record.isCompleted } fun continueWatchingProgressEntries( progressRecords: List, limit: Int = DefaultContinueWatchingLimit, ): List { - val inProgress = progressRecords.filterNot { record -> record.isCompleted } - val (episodes, nonEpisodes) = inProgress.partition { record -> - record.seasonNumber != null && record.episodeNumber != null + val (seriesEntries, nonSeriesEntries) = progressRecords.partition { record -> + record.content.type.isSeriesLikeWatchingContentType() || + (record.seasonNumber != null && record.episodeNumber != null) } - val latestPerSeries = episodes - .sortedByDescending { record -> record.lastUpdatedEpochMs } - .distinctBy { record -> record.content.id } - return (nonEpisodes + latestPerSeries) + val latestPerSeries = seriesEntries + .groupBy { record -> record.content.id.trim() } + .values + .mapNotNull { entries -> entries.maxWithOrNull(watchingProgressRecencyComparator) } + + return (nonSeriesEntries + latestPerSeries) + .filterNot { record -> record.isCompleted } .sortedByDescending { record -> record.lastUpdatedEpochMs } .take(limit) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingModels.kt index 5f9c7473..0430ddb9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingModels.kt @@ -28,6 +28,7 @@ data class WatchingProgressRecord( val isCompleted: Boolean = false, val episodeTitle: String? = null, val episodeThumbnail: String? = null, + val identityKey: String = videoId, ) data class WatchingReleasedEpisode( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/ProgressSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/ProgressSyncAdapter.kt index dfb8d48e..d47ff2b4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/ProgressSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/ProgressSyncAdapter.kt @@ -11,15 +11,16 @@ data class ProgressSyncRecord( val position: Long = 0L, val duration: Long = 0L, val lastWatched: Long = 0L, + val progressKey: String = "", ) data class ProgressDeltaEvent( val eventId: Long, val operation: String, val progressKey: String, - val contentId: String, - val contentType: String, - val videoId: String, + val contentId: String = "", + val contentType: String = "", + val videoId: String = "", val season: Int? = null, val episode: Int? = null, val position: Long = 0L, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt index 476bc766..9b9b00de 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt @@ -3,6 +3,7 @@ package com.nuvio.app.features.watching.sync import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.core.sync.putSyncOriginClientId import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watchprogress.resolvedProgressKey import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.rpc import kotlinx.serialization.SerialName @@ -73,6 +74,7 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { val serverEntries = result.decodeList() return serverEntries.map { entry -> ProgressSyncRecord( + progressKey = entry.progressKey, contentId = entry.contentId, contentType = entry.contentType, videoId = entry.videoId, @@ -99,7 +101,7 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { position = entry.lastPositionMs, duration = entry.durationMs, lastWatched = entry.lastUpdatedEpochMs, - progressKey = progressKeyForEntry(entry), + progressKey = entry.resolvedProgressKey(), ) } val params = buildJsonObject { @@ -114,13 +116,7 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { profileId: Int, entries: Collection, ) { - val progressKeys = entries.map { entry -> - if (entry.seasonNumber != null && entry.episodeNumber != null) { - "${entry.parentMetaId}_s${entry.seasonNumber}e${entry.episodeNumber}" - } else { - entry.parentMetaId - } - } + val progressKeys = entries.map { entry -> entry.resolvedProgressKey() } val params = buildJsonObject { put("p_profile_id", profileId) put("p_keys", json.encodeToJsonElement(progressKeys)) @@ -129,12 +125,6 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { SupabaseProvider.client.postgrest.rpc("sync_delete_watch_progress", params) } - private fun progressKeyForEntry(entry: WatchProgressEntry): String = - if (entry.seasonNumber != null && entry.episodeNumber != null) { - "${entry.parentMetaId}_s${entry.seasonNumber}e${entry.episodeNumber}" - } else { - entry.parentMetaId - } } @Serializable @@ -155,9 +145,9 @@ private data class WatchProgressDeltaSyncEntry( @SerialName("event_id") val eventId: Long, val operation: String, @SerialName("progress_key") val progressKey: String, - @SerialName("content_id") val contentId: String, - @SerialName("content_type") val contentType: String, - @SerialName("video_id") val videoId: String, + @SerialName("content_id") val contentId: String = "", + @SerialName("content_type") val contentType: String = "", + @SerialName("video_id") val videoId: String = "", val season: Int? = null, val episode: Int? = null, val position: Long = 0, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt index 13e2ebd5..51980b2c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt @@ -59,7 +59,7 @@ fun parseReleaseDateToEpochMs(raw: String?): Long? { if (epochMs != null) return epochMs val datePart = isoCalendarDateOrNull(trimmed) ?: return null - return parseTraktIsoDateTimeToEpochMs("${datePart}T00:00:00Z") + return CurrentDateProvider.localStartOfDayEpochMs(datePart) } class ReleaseAlertState( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt index 13a43531..2082b4a9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt @@ -53,8 +53,17 @@ data class CachedInProgressItem( val duration: Long, val lastWatched: Long, val progressPercent: Float? = null, + val progressKey: String? = null, ) +internal fun CachedInProgressItem.resolvedProgressKey(): String = + progressKey?.takeIf(String::isNotBlank) + ?: buildWatchProgressKey( + contentId = contentId, + seasonNumber = season, + episodeNumber = episode, + ) + @Serializable private data class CachedEnrichmentPayload( val nextUp: List = emptyList(), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.kt index 83091292..72bdc7be 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.kt @@ -2,5 +2,5 @@ package com.nuvio.app.features.watchprogress expect object CurrentDateProvider { fun todayIsoDate(): String + fun localStartOfDayEpochMs(isoDate: String): Long? } - diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentity.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentity.kt new file mode 100644 index 00000000..c66d53d6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentity.kt @@ -0,0 +1,190 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.features.watching.sync.ProgressDeltaEvent +import com.nuvio.app.features.watching.sync.ProgressSyncRecord + +/** + * Stable storage/sync identity for a progress row. + * + * [WatchProgressEntry.videoId] identifies the playable video and is not unique + * across metadata aliases. The server's progress key identifies the logical + * row and must therefore be used for snapshot merges and delta deletes. + */ +internal fun buildWatchProgressKey( + contentId: String, + seasonNumber: Int?, + episodeNumber: Int?, +): String = + if (seasonNumber != null && episodeNumber != null) { + "${contentId}_s${seasonNumber}e${episodeNumber}" + } else { + contentId + } + +internal fun WatchProgressEntry.resolvedProgressKey(): String = + progressKey + ?.takeIf(String::isNotBlank) + ?: buildWatchProgressKey( + contentId = parentMetaId, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + ) + +internal fun WatchProgressEntry.withResolvedProgressKey(): WatchProgressEntry { + val resolved = resolvedProgressKey() + return if (progressKey == resolved) this else copy(progressKey = resolved) +} + +internal fun ProgressSyncRecord.resolvedProgressKey(): String = + progressKey.takeIf(String::isNotBlank) ?: run { + buildWatchProgressKey( + contentId = contentId, + seasonNumber = season, + episodeNumber = episode, + ) + } + +internal fun ProgressDeltaEvent.resolvedProgressKey(): String = + progressKey.takeIf(String::isNotBlank) ?: run { + buildWatchProgressKey( + contentId = contentId, + seasonNumber = season, + episodeNumber = episode, + ) + } + +internal val watchProgressEntryFreshnessComparator: Comparator = + compareBy { entry -> entry.lastUpdatedEpochMs } + .thenBy { entry -> entry.lastPositionMs } + .thenBy { entry -> entry.durationMs } + .thenBy { entry -> entry.videoId } + .thenBy { entry -> entry.parentMetaId } + .thenBy { entry -> entry.contentType } + .thenBy { entry -> entry.seasonNumber ?: Int.MIN_VALUE } + .thenBy { entry -> entry.episodeNumber ?: Int.MIN_VALUE } + .thenBy(WatchProgressEntry::isCompleted) + .thenBy { entry -> entry.normalizedProgressPercent ?: Float.NEGATIVE_INFINITY } + .thenBy(WatchProgressEntry::source) + .thenBy(WatchProgressEntry::parentMetaType) + .thenBy(WatchProgressEntry::title) + .thenBy { entry -> entry.logo.orEmpty() } + .thenBy { entry -> entry.poster.orEmpty() } + .thenBy { entry -> entry.background.orEmpty() } + .thenBy { entry -> entry.episodeTitle.orEmpty() } + .thenBy { entry -> entry.episodeThumbnail.orEmpty() } + .thenBy { entry -> entry.providerName.orEmpty() } + .thenBy { entry -> entry.providerAddonId.orEmpty() } + .thenBy { entry -> entry.lastStreamTitle.orEmpty() } + .thenBy { entry -> entry.lastStreamSubtitle.orEmpty() } + .thenBy { entry -> entry.pauseDescription.orEmpty() } + .thenBy { entry -> entry.lastSourceUrl.orEmpty() } + .thenBy { entry -> entry.progressKey.orEmpty() } + +internal fun WatchProgressEntry.isFresherThan(other: WatchProgressEntry): Boolean = + watchProgressEntryFreshnessComparator.compare(this, other) > 0 + +/** Keeps one newest row per logical progress key, independent of input order. */ +internal fun Collection.newestByProgressKey(): Map { + val result = linkedMapOf() + forEach { rawEntry -> + val entry = rawEntry.withResolvedProgressKey() + val key = entry.resolvedProgressKey() + val existing = result[key] + if (existing == null || entry.isFresherThan(existing)) { + result[key] = entry + } + } + return result +} + +/** + * Reuses an existing opaque server key when playback updates the same logical + * content row. Exact playback-id matches win; freshness breaks remaining ties. + */ +internal fun Collection.resolveIdentityForUpsert( + candidate: WatchProgressEntry, +): WatchProgressEntry { + val logicalMatches = filter { existing -> + existing.parentMetaId == candidate.parentMetaId && + existing.seasonNumber == candidate.seasonNumber && + existing.episodeNumber == candidate.episodeNumber + } + val existing = logicalMatches + .filter { entry -> entry.videoId == candidate.videoId } + .maxWithOrNull(watchProgressEntryFreshnessComparator) + ?: logicalMatches.maxWithOrNull(watchProgressEntryFreshnessComparator) + val progressKey = existing?.resolvedProgressKey() ?: candidate.resolvedProgressKey() + return candidate.copy(progressKey = progressKey) +} + +internal fun Collection.resolveProgressForVideo( + videoId: String, + parentMetaId: String? = null, + seasonNumber: Int? = null, + episodeNumber: Int? = null, +): WatchProgressEntry? { + val candidates = asSequence() + .filter { entry -> entry.videoId == videoId } + .filter { entry -> parentMetaId == null || entry.parentMetaId == parentMetaId } + .filter { entry -> seasonNumber == null || entry.seasonNumber == seasonNumber } + .filter { entry -> episodeNumber == null || entry.episodeNumber == episodeNumber } + .toList() + if (candidates.isEmpty()) return null + val logicalIdentities = candidates.mapTo(mutableSetOf()) { entry -> + Triple(entry.parentMetaId, entry.seasonNumber, entry.episodeNumber) + } + if (logicalIdentities.size > 1) return null + return candidates.maxWithOrNull(watchProgressEntryFreshnessComparator) +} + +internal data class WatchProgressIdentityReconciliation( + val entries: List, + val migratedKeys: Map, +) + +/** + * Migrates a legacy synthetic local key to the server's sole opaque key for + * the same logical content row. Exact keys always win, and ambiguous server + * identities are intentionally left untouched. + */ +internal fun reconcileLocalProgressKeysWithSnapshot( + serverEntries: Collection, + localEntries: Collection, +): WatchProgressIdentityReconciliation { + val serverKeys = serverEntries.mapTo(mutableSetOf(), ProgressSyncRecord::resolvedProgressKey) + val uniqueServerKeyByLogicalIdentity = serverEntries + .groupBy { record -> + Triple(record.contentId, record.season, record.episode) + } + .mapNotNull { (identity, records) -> + records.map(ProgressSyncRecord::resolvedProgressKey) + .distinct() + .singleOrNull() + ?.let { key -> identity to key } + } + .toMap() + val migrations = linkedMapOf() + val reconciled = localEntries.map { entry -> + val localKey = entry.resolvedProgressKey() + if (localKey in serverKeys) return@map entry + + val syntheticKey = buildWatchProgressKey( + contentId = entry.parentMetaId, + seasonNumber = entry.seasonNumber, + episodeNumber = entry.episodeNumber, + ) + val serverKey = uniqueServerKeyByLogicalIdentity[ + Triple(entry.parentMetaId, entry.seasonNumber, entry.episodeNumber) + ] + if (localKey == syntheticKey && serverKey != null && serverKey != localKey) { + migrations[localKey] = serverKey + entry.copy(progressKey = serverKey) + } else { + entry + } + } + return WatchProgressIdentityReconciliation( + entries = reconciled, + migratedKeys = migrations, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt index 06978237..aca40d7f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt @@ -52,6 +52,8 @@ data class WatchProgressEntry( val isCompleted: Boolean = false, val progressPercent: Float? = null, val source: String = WatchProgressSourceLocal, + /** Stable server/storage identity. [videoId] remains the playback identity. */ + val progressKey: String? = null, ) { val normalizedProgressPercent: Float? get() = progressPercent?.coerceIn(0f, 100f) @@ -81,10 +83,11 @@ data class WatchProgressEntry( fun normalizedCompletion(): WatchProgressEntry { val completed = isEffectivelyCompleted - val normalizedPositionMs = when { - completed && durationMs > 0L -> durationMs - else -> lastPositionMs.coerceAtLeast(0L) - } + // Preserve the upstream position. Completion is a state derived at the + // 90% threshold, not evidence that playback reached the exact duration. + // Rewriting it to duration made a pulled 94% row oscillate between 94% + // and 100% across reloads. + val normalizedPositionMs = lastPositionMs.coerceAtLeast(0L) val normalizedPercent = when { normalizedProgressPercent != null -> normalizedProgressPercent completed && durationMs <= 0L -> 100f @@ -123,8 +126,35 @@ data class WatchProgressUiState( val entries: List = emptyList(), val hasLoadedRemoteProgress: Boolean = false, ) { + val byProgressKey: Map + get() = entries.newestByProgressKey() + + /** Secondary compatibility lookup; multiple server rows may share a video id. */ val byVideoId: Map - get() = entries.associateBy { it.videoId } + get() = entries + .groupBy(WatchProgressEntry::videoId) + .mapNotNull { (videoId, candidates) -> + candidates.resolveProgressForVideo(videoId)?.let { entry -> videoId to entry } + } + .toMap() + + fun byVideoIdForContent(parentMetaId: String): Map = + entries + .filter { entry -> entry.parentMetaId == parentMetaId } + .groupBy(WatchProgressEntry::videoId) + .mapValues { (_, candidates) -> candidates.maxWith(watchProgressEntryFreshnessComparator) } + + fun progressForVideo( + videoId: String, + parentMetaId: String? = null, + seasonNumber: Int? = null, + episodeNumber: Int? = null, + ): WatchProgressEntry? = entries.resolveProgressForVideo( + videoId = videoId, + parentMetaId = parentMetaId, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + ) val continueWatchingEntries: List get() = entries.continueWatchingEntries(limit = ContinueWatchingLimit) @@ -205,7 +235,10 @@ internal fun nextUpDismissKey( internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { val normalizedEntry = normalizedCompletion() - val cloudPosterUrl = normalizedEntry.cloudLibraryPosterFallbackUrl() + val cloudPosterUrl = normalizedEntry.cloudLibraryPosterFallbackUrl().nonBlankOrNull() + val resolvedPoster = normalizedEntry.poster.nonBlankOrNull() ?: cloudPosterUrl + val resolvedBackground = normalizedEntry.background.nonBlankOrNull() + val resolvedEpisodeThumbnail = normalizedEntry.episodeThumbnail.nonBlankOrNull() val explicitResumeProgressFraction = normalizedEntry.normalizedProgressPercent ?.takeIf { durationMs <= 0L && it > 0f } ?.let { explicitPercent -> (explicitPercent / 100f).coerceIn(0f, 1f) } @@ -220,15 +253,15 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { episodeNumber = normalizedEntry.episodeNumber, episodeTitle = normalizedEntry.episodeTitle, ), - imageUrl = normalizedEntry.episodeThumbnail ?: normalizedEntry.background ?: normalizedEntry.poster ?: cloudPosterUrl, - logo = normalizedEntry.logo, - poster = normalizedEntry.poster ?: cloudPosterUrl, - background = normalizedEntry.background, + imageUrl = resolvedEpisodeThumbnail ?: resolvedBackground ?: resolvedPoster, + logo = normalizedEntry.logo.nonBlankOrNull(), + poster = resolvedPoster, + background = resolvedBackground, seasonNumber = normalizedEntry.seasonNumber, episodeNumber = normalizedEntry.episodeNumber, - episodeTitle = normalizedEntry.episodeTitle, - episodeThumbnail = normalizedEntry.episodeThumbnail, - pauseDescription = normalizedEntry.pauseDescription, + episodeTitle = normalizedEntry.episodeTitle.nonBlankOrNull(), + episodeThumbnail = resolvedEpisodeThumbnail, + pauseDescription = normalizedEntry.pauseDescription.nonBlankOrNull(), released = null, isNextUp = false, nextUpSeedSeasonNumber = null, @@ -261,6 +294,10 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem( nextSeasonNumber = nextEpisode.season, releasedIso = nextEpisode.released, ) + val resolvedPoster = poster.nonBlankOrNull() + val resolvedBackground = background.nonBlankOrNull() + val resolvedCurrentEpisodeThumbnail = episodeThumbnail.nonBlankOrNull() + val resolvedNextEpisodeThumbnail = nextEpisode.thumbnail.nonBlankOrNull() return ContinueWatchingItem( parentMetaId = parentMetaId, parentMetaType = parentMetaType, @@ -276,16 +313,19 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem( episodeNumber = nextEpisode.episode, episodeTitle = nextEpisode.title, ), - imageUrl = nextEpisode.thumbnail ?: episodeThumbnail ?: background ?: poster, - logo = logo, - poster = poster, - background = background, + imageUrl = resolvedNextEpisodeThumbnail + ?: resolvedCurrentEpisodeThumbnail + ?: resolvedBackground + ?: resolvedPoster, + logo = logo.nonBlankOrNull(), + poster = resolvedPoster, + background = resolvedBackground, seasonNumber = nextEpisode.season, episodeNumber = nextEpisode.episode, - episodeTitle = nextEpisode.title, - episodeThumbnail = nextEpisode.thumbnail, - pauseDescription = nextEpisode.overview, - released = nextEpisode.released, + episodeTitle = nextEpisode.title.nonBlankOrNull(), + episodeThumbnail = resolvedNextEpisodeThumbnail, + pauseDescription = nextEpisode.overview.nonBlankOrNull(), + released = nextEpisode.released.nonBlankOrNull(), isNextUp = true, nextUpSeedSeasonNumber = seasonNumber, nextUpSeedEpisodeNumber = episodeNumber, @@ -298,6 +338,8 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem( ) } +private fun String?.nonBlankOrNull(): String? = this?.takeIf { it.isNotBlank() } + internal fun buildContinueWatchingEpisodeSubtitle( seasonNumber: Int?, episodeNumber: Int?, 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 a814c830..578f0d83 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 @@ -24,13 +24,14 @@ import com.nuvio.app.features.watching.sync.ProgressSyncRecord import com.nuvio.app.features.watching.sync.ProgressSyncAdapter import com.nuvio.app.features.watching.sync.SupabaseProgressSyncAdapter import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -42,32 +43,117 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit -import kotlinx.coroutines.withTimeoutOrNull private const val WATCH_PROGRESS_METADATA_RESOLUTION_CONCURRENCY = 4 private const val WATCH_PROGRESS_METADATA_RESOLUTION_LIMIT = 64 +private const val WATCH_PROGRESS_METADATA_FETCH_ATTEMPTS = 3 +private const val WATCH_PROGRESS_METADATA_RETRY_BASE_DELAY_MS = 750L private const val WATCH_PROGRESS_DELTA_PAGE_SIZE = 900 private const val WATCH_PROGRESS_DELTA_OPERATION_UPSERT = "upsert" private const val WATCH_PROGRESS_DELTA_OPERATION_DELETE = "delete" private data class RemoteMetadataResolutionResult( - val key: Pair, val entries: List, val meta: MetaDetails?, ) private data class MetadataProviderReadiness( val providers: List, - val isRefreshing: Boolean, ) { val fingerprint: String get() = providers.map(AddonManifest::transportUrl).sorted().joinToString(separator = "|") val isReady: Boolean - get() = providers.isNotEmpty() && !isRefreshing + get() = providers.isNotEmpty() +} - val isSettledWithoutProviders: Boolean - get() = !isRefreshing && providers.isEmpty() +internal class MetadataResolutionRetryCoordinator { + private val lock = SynchronizedObject() + private var generation = 0L + private var activeGeneration: Long? = null + private var activeProviderFingerprint: String? = null + private var lastRequestedProviderFingerprint: String? = null + private var pendingProviderFingerprint: String? = null + + fun reset() { + synchronized(lock) { + generation += 1L + activeGeneration = null + activeProviderFingerprint = null + lastRequestedProviderFingerprint = null + pendingProviderFingerprint = null + } + } + + fun invalidateActiveResolution() { + synchronized(lock) { + generation += 1L + activeGeneration = null + activeProviderFingerprint = null + pendingProviderFingerprint = null + } + } + + fun requestForProviders(providerFingerprint: String): Boolean = + synchronized(lock) { + if (activeGeneration != null) { + if (providerFingerprint != activeProviderFingerprint) { + pendingProviderFingerprint = providerFingerprint + } + return@synchronized false + } + if (providerFingerprint == lastRequestedProviderFingerprint) { + return@synchronized false + } + + lastRequestedProviderFingerprint = providerFingerprint + true + } + + fun beginResolution(providerFingerprint: String?): Long = + synchronized(lock) { + generation += 1L + activeGeneration = generation + activeProviderFingerprint = providerFingerprint + pendingProviderFingerprint = null + if (providerFingerprint != null) { + lastRequestedProviderFingerprint = providerFingerprint + } + generation + } + + fun providersObservedBeforeFetch( + resolutionGeneration: Long, + providerFingerprint: String, + ) { + synchronized(lock) { + if (activeGeneration != resolutionGeneration) return@synchronized + activeProviderFingerprint = providerFingerprint + lastRequestedProviderFingerprint = providerFingerprint + if (pendingProviderFingerprint == providerFingerprint) { + pendingProviderFingerprint = null + } + } + } + + fun finishResolution( + resolutionGeneration: Long, + currentProviderFingerprint: String?, + ): Boolean = synchronized(lock) { + if (activeGeneration != resolutionGeneration) return@synchronized false + + activeGeneration = null + val shouldRetry = currentProviderFingerprint != null && + currentProviderFingerprint != activeProviderFingerprint && + (pendingProviderFingerprint != null || + currentProviderFingerprint != lastRequestedProviderFingerprint) + activeProviderFingerprint = null + pendingProviderFingerprint = null + if (shouldRetry) { + lastRequestedProviderFingerprint = currentProviderFingerprint + } + shouldRetry + } } private data class WatchProgressDeltaApplyResult( @@ -77,6 +163,49 @@ private data class WatchProgressDeltaApplyResult( val changed: Boolean, ) +internal enum class WatchProgressDeltaDecisionType { + UPSERT, + DELETE, + PRESERVE_LOCAL, + IGNORE, +} + +internal data class WatchProgressDeltaDecision( + val type: WatchProgressDeltaDecisionType, + val updatedEntry: WatchProgressEntry? = null, + val clearsDirtyProgress: Boolean = false, +) + +internal fun enrichWatchProgressEntry( + current: WatchProgressEntry, + meta: MetaDetails, +): WatchProgressEntry { + val episodeVideo = if (current.seasonNumber != null && current.episodeNumber != null) { + meta.videos.firstOrNull { video -> + video.season == current.seasonNumber && video.episode == current.episodeNumber + } + } else { + null + } + return current.copy( + title = meta.name.takeIf(String::isNotBlank) ?: current.title, + poster = meta.poster?.takeIf(String::isNotBlank) ?: current.poster, + background = meta.background?.takeIf(String::isNotBlank) ?: current.background, + logo = meta.logo?.takeIf(String::isNotBlank) ?: current.logo, + episodeTitle = episodeVideo?.title?.takeIf(String::isNotBlank) ?: current.episodeTitle, + episodeThumbnail = episodeVideo?.thumbnail?.takeIf(String::isNotBlank) ?: current.episodeThumbnail, + pauseDescription = episodeVideo?.overview?.takeIf(String::isNotBlank) + ?: meta.description?.takeIf(String::isNotBlank) + ?: current.pauseDescription, + ) +} + +internal fun WatchProgressEntry.needsRemoteMetadataEnrichment(): Boolean = + title.isBlank() || + title.equals(parentMetaId, ignoreCase = true) || + poster.isNullOrBlank() || + background.isNullOrBlank() + object WatchProgressRepository { private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val accountScopeLock = SynchronizedObject() @@ -88,19 +217,21 @@ object WatchProgressRepository { val uiState: StateFlow = _uiState.asStateFlow() private var hasLoaded = false + private var hasLoadedNuvioRemoteProgress = false private var currentProfileId: Int = 1 private var profileGeneration: Long = 0L private var activeSource: WatchProgressSource = WatchProgressSource.NUVIO_SYNC private val _activeSourceState = MutableStateFlow(activeSource) internal val activeSourceState: StateFlow = _activeSourceState.asStateFlow() private val entriesLock = SynchronizedObject() - private var entriesByVideoId: MutableMap = mutableMapOf() + private var entriesByProgressKey: MutableMap = mutableMapOf() + private var dirtyProgressKeys: MutableSet = mutableSetOf() private var metadataResolutionJob: Job? = null + private val metadataResolutionRetryCoordinator = MetadataResolutionRetryCoordinator() private val nuvioPullMutex = Mutex() private var lastSuccessfulPushEpochMs = 0L private var deltaCursorEventId = 0L private var deltaInitialized = false - private var lastAddonMetadataReadyFingerprint: String? = null internal var syncAdapter: ProgressSyncAdapter = SupabaseProgressSyncAdapter init { @@ -156,12 +287,12 @@ object WatchProgressRepository { } } previousAccountJob.cancel() - metadataResolutionJob?.cancel() + cancelMetadataResolution(resetProviderHistory = true) hasLoaded = false + hasLoadedNuvioRemoteProgress = false currentProfileId = 1 profileGeneration += 1L updateActiveSource(WatchProgressSource.NUVIO_SYNC) - lastAddonMetadataReadyFingerprint = null clearLocalEntries() lastSuccessfulPushEpochMs = 0L deltaCursorEventId = 0L @@ -172,11 +303,11 @@ object WatchProgressRepository { } private fun loadFromDisk(profileId: Int) { - metadataResolutionJob?.cancel() + cancelMetadataResolution(resetProviderHistory = true) currentProfileId = profileId profileGeneration += 1L hasLoaded = true - lastAddonMetadataReadyFingerprint = null + hasLoadedNuvioRemoteProgress = false clearLocalEntries() val payload = WatchProgressStorage.loadPayload(profileId).orEmpty().trim() @@ -186,6 +317,7 @@ object WatchProgressRepository { deltaCursorEventId = storedPayload.deltaCursorEventId deltaInitialized = storedPayload.deltaInitialized replaceLocalEntries(storedPayload.entries) + replaceDirtyProgressKeys(storedPayload.dirtyProgressKeys) } else { lastSuccessfulPushEpochMs = 0L deltaCursorEventId = 0L @@ -252,9 +384,11 @@ object WatchProgressRepository { } updateActiveSource(source) - metadataResolutionJob?.cancel() + cancelMetadataResolution(resetProviderHistory = false) if (source == WatchProgressSource.TRAKT) { TraktProgressRepository.clearLocalState() + } else { + hasLoadedNuvioRemoteProgress = false } publish() if (source == WatchProgressSource.NUVIO_SYNC) { @@ -331,23 +465,22 @@ object WatchProgressRepository { ): Boolean { val authState = AuthRepository.state.value if (authState !is AuthState.Authenticated || authState.isAnonymous) { + // There is no upstream source for this account, so local state is authoritative. + hasLoadedNuvioRemoteProgress = true publish() return true } return nuvioPullMutex.withLock { try { - val pullStartedEpochMs = WatchProgressClock.nowEpochMs() if (force) { pullNuvioSnapshotFromServer( profileId = profileId, - pullStartedEpochMs = pullStartedEpochMs, operationGeneration = operationGeneration, ) } else { pullSupabaseDeltaFromServer( profileId = profileId, - pullStartedEpochMs = pullStartedEpochMs, operationGeneration = operationGeneration, ) } @@ -363,7 +496,6 @@ object WatchProgressRepository { private suspend fun pullNuvioSnapshotFromServer( profileId: Int, - pullStartedEpochMs: Long, operationGeneration: Long, ) { val cursorBeforeSnapshot = try { @@ -377,7 +509,6 @@ object WatchProgressRepository { pullFullFromAdapter( profileId = profileId, - pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = cursorBeforeSnapshot == null, operationGeneration = operationGeneration, preserveLocalEntries = true, @@ -393,7 +524,6 @@ object WatchProgressRepository { private suspend fun pullSupabaseDeltaFromServer( profileId: Int, - pullStartedEpochMs: Long, operationGeneration: Long, ) { if (!isActiveOperation(profileId, operationGeneration)) return @@ -415,7 +545,6 @@ object WatchProgressRepository { log.d { "Watch progress delta cursor unavailable for profile $profileId; using snapshot fallback" } pullFullFromAdapter( profileId = profileId, - pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = true, operationGeneration = operationGeneration, ) @@ -425,7 +554,6 @@ object WatchProgressRepository { log.d { "Watch progress delta cursor before snapshot for profile $profileId is $cursorBeforeSnapshot" } pullFullFromAdapter( profileId = profileId, - pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = false, operationGeneration = operationGeneration, ) @@ -445,6 +573,7 @@ object WatchProgressRepository { var totalUpserts = 0 var totalDeletes = 0 var preservedLocalItems = false + var cursorAdvanced = false var page = 1 while (true) { @@ -461,7 +590,6 @@ object WatchProgressRepository { log.w { "Watch progress delta pull unavailable, falling back to full pull: ${error.message}" } pullFullFromAdapter( profileId = profileId, - pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = true, operationGeneration = operationGeneration, ) @@ -482,15 +610,14 @@ object WatchProgressRepository { "first=$firstEvent last=$lastEvent upserts=$eventUpserts deletes=$eventDeletes" } - val pageResult = applyWatchProgressDeltaEvents( - events = events, - pullStartedEpochMs = pullStartedEpochMs, - ) + val pageResult = applyWatchProgressDeltaEvents(events = events) changed = pageResult.changed || changed totalUpserts += pageResult.appliedUpserts totalDeletes += pageResult.appliedDeletes preservedLocalItems = preservedLocalItems || pageResult.preservedLocalItems + val previousCursor = cursor cursor = maxOf(cursor, events.maxOf { it.eventId }) + cursorAdvanced = cursorAdvanced || cursor > previousCursor deltaCursorEventId = cursor deltaInitialized = true log.d { @@ -504,9 +631,15 @@ object WatchProgressRepository { } hasLoaded = true - if (changed) { + val remoteReadinessChanged = !hasLoadedNuvioRemoteProgress + hasLoadedNuvioRemoteProgress = true + if (changed || remoteReadinessChanged) { publish() + } + if (changed || cursorAdvanced) { persist() + } + if (changed) { resolveRemoteMetadata() } log.d { @@ -518,7 +651,6 @@ object WatchProgressRepository { private suspend fun pullFullFromAdapter( profileId: Int, - pullStartedEpochMs: Long, resetDeltaState: Boolean, operationGeneration: Long, preserveLocalEntries: Boolean = true, @@ -529,24 +661,43 @@ object WatchProgressRepository { "Watch progress snapshot fetched ${serverEntries.size} entries for profile $profileId " + "resetDeltaState=$resetDeltaState preserveLocalEntries=$preserveLocalEntries" } + val localBeforePull = localEntriesSnapshot() + val reconciliation = reconcileLocalProgressKeysWithSnapshot( + serverEntries = serverEntries, + localEntries = localBeforePull, + ) + migrateDirtyProgressKeys(reconciliation.migratedKeys) + val dirtyBeforeApply = dirtyProgressKeysSnapshot() val updatedEntries = if (preserveLocalEntries) { mergeWatchProgressEntriesPreservingUnsynced( serverEntries = serverEntries, - localEntries = localEntriesSnapshot(), - lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, - pullStartedEpochMs = pullStartedEpochMs, + localEntries = reconciliation.entries, + dirtyProgressKeys = dirtyBeforeApply, ) } else { - serverEntries.associate { record -> - record.videoId to record.toWatchProgressEntry(cached = null) + val newestRemoteByKey = linkedMapOf() + serverEntries.forEach { record -> + val key = record.resolvedProgressKey() + val candidate = record.toWatchProgressEntry(cached = null) + val existing = newestRemoteByKey[key] + if (existing == null || candidate.isFresherThan(existing)) { + newestRemoteByKey[key] = candidate + } } + newestRemoteByKey } replaceLocalEntries(updatedEntries) + acknowledgeDirtyProgressFromSnapshot( + serverEntries = serverEntries, + localEntriesBeforeApply = reconciliation.entries, + dirtyKeysBeforeApply = dirtyBeforeApply, + ) if (resetDeltaState) { deltaCursorEventId = 0L deltaInitialized = false } hasLoaded = true + hasLoadedNuvioRemoteProgress = true publish() persist() resolveRemoteMetadata() @@ -558,42 +709,56 @@ object WatchProgressRepository { private fun applyWatchProgressDeltaEvents( events: Collection, - pullStartedEpochMs: Long, ): WatchProgressDeltaApplyResult { var changed = false var appliedUpserts = 0 var appliedDeletes = 0 var preservedLocalItems = false - events.forEach { event -> - if (event.videoId.isBlank()) return@forEach + val latestEventByProgressKey = linkedMapOf() + events.sortedBy(ProgressDeltaEvent::eventId).forEach { event -> + val progressKey = event.resolvedProgressKey() + if (progressKey.isBlank()) { + return@forEach + } when (event.operation.lowercase()) { + WATCH_PROGRESS_DELTA_OPERATION_DELETE -> { + latestEventByProgressKey[progressKey] = event + } WATCH_PROGRESS_DELTA_OPERATION_UPSERT -> { - val current = localEntry(event.videoId) - val updated = event.toProgressSyncRecord().toWatchProgressEntry(cached = current) - if (current != updated) { - upsertLocalEntry(updated) - changed = true - appliedUpserts += 1 + if (event.videoId.isNotBlank()) { + latestEventByProgressKey[progressKey] = event } } - WATCH_PROGRESS_DELTA_OPERATION_DELETE -> { - val localEntry = localEntry(event.videoId) - if ( - localEntry != null && - shouldPreserveLocalWatchProgressEntry( - localEntry = localEntry, - lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, - pullStartedEpochMs = pullStartedEpochMs, - ) - ) { - preservedLocalItems = true - return@forEach - } - if (removeLocalEntry(event.videoId) != null) { + else -> Unit + } + } + + latestEventByProgressKey.forEach { (progressKey, event) -> + val current = localEntry(progressKey) + val decision = decideWatchProgressDeltaEvent( + current = current, + event = event, + isLocalDirty = progressKey in dirtyProgressKeysSnapshot(), + ) + when (decision.type) { + WatchProgressDeltaDecisionType.UPSERT -> { + upsertLocalEntry(requireNotNull(decision.updatedEntry)) + changed = true + appliedUpserts += 1 + } + WatchProgressDeltaDecisionType.DELETE -> { + if (removeLocalEntry(progressKey) != null) { changed = true appliedDeletes += 1 } } + WatchProgressDeltaDecisionType.PRESERVE_LOCAL -> { + preservedLocalItems = true + } + WatchProgressDeltaDecisionType.IGNORE -> Unit + } + if (decision.clearsDirtyProgress) { + clearProgressDirty(progressKey) } } return WatchProgressDeltaApplyResult( @@ -604,6 +769,49 @@ object WatchProgressRepository { ) } + internal fun decideWatchProgressDeltaEvent( + current: WatchProgressEntry?, + event: ProgressDeltaEvent, + isLocalDirty: Boolean, + ): WatchProgressDeltaDecision = when (event.operation.lowercase()) { + WATCH_PROGRESS_DELTA_OPERATION_UPSERT -> { + if (event.videoId.isBlank()) { + WatchProgressDeltaDecision(WatchProgressDeltaDecisionType.IGNORE) + } else { + val updated = event.toProgressSyncRecord().toWatchProgressEntry(cached = current) + when { + current == null -> + WatchProgressDeltaDecision( + type = WatchProgressDeltaDecisionType.UPSERT, + updatedEntry = updated, + clearsDirtyProgress = true, + ) + isLocalDirty && current.isFresherThan(updated) -> + WatchProgressDeltaDecision(WatchProgressDeltaDecisionType.PRESERVE_LOCAL) + current == updated -> + WatchProgressDeltaDecision( + type = WatchProgressDeltaDecisionType.IGNORE, + clearsDirtyProgress = true, + ) + else -> WatchProgressDeltaDecision( + type = WatchProgressDeltaDecisionType.UPSERT, + updatedEntry = updated, + clearsDirtyProgress = true, + ) + } + } + } + WATCH_PROGRESS_DELTA_OPERATION_DELETE -> when { + current == null -> WatchProgressDeltaDecision(WatchProgressDeltaDecisionType.IGNORE) + isLocalDirty -> WatchProgressDeltaDecision(WatchProgressDeltaDecisionType.PRESERVE_LOCAL) + else -> WatchProgressDeltaDecision( + type = WatchProgressDeltaDecisionType.DELETE, + clearsDirtyProgress = true, + ) + } + else -> WatchProgressDeltaDecision(WatchProgressDeltaDecisionType.IGNORE) + } + private fun ProgressSyncRecord.toWatchProgressEntry(cached: WatchProgressEntry?): WatchProgressEntry = WatchProgressEntry( contentType = contentType, @@ -628,10 +836,12 @@ object WatchProgressRepository { pauseDescription = cached?.pauseDescription, lastSourceUrl = cached?.lastSourceUrl, isCompleted = isWatchProgressComplete(position, duration, false), + progressKey = resolvedProgressKey(), ) private fun ProgressDeltaEvent.toProgressSyncRecord(): ProgressSyncRecord = ProgressSyncRecord( + progressKey = progressKey, contentId = contentId, contentType = contentType, videoId = videoId, @@ -642,45 +852,40 @@ object WatchProgressRepository { lastWatched = lastWatched, ) - private fun mergeWatchProgressEntriesPreservingUnsynced( + internal fun mergeWatchProgressEntriesPreservingUnsynced( serverEntries: Collection, localEntries: Collection, - lastSuccessfulPushEpochMs: Long, - pullStartedEpochMs: Long, + dirtyProgressKeys: Set, ): Map { - val localByVideoId = localEntries.associateBy { entry -> entry.videoId } - val merged = serverEntries.associate { record -> - record.videoId to record.toWatchProgressEntry(cached = localByVideoId[record.videoId]) - }.toMutableMap() + val reconciliation = reconcileLocalProgressKeysWithSnapshot( + serverEntries = serverEntries, + localEntries = localEntries, + ) + val effectiveDirtyKeys = dirtyProgressKeys.mapTo(mutableSetOf()) { key -> + reconciliation.migratedKeys[key] ?: key + } + val localByProgressKey = reconciliation.entries.newestByProgressKey() + val merged = linkedMapOf() + serverEntries.forEach { record -> + val progressKey = record.resolvedProgressKey() + val candidate = record.toWatchProgressEntry(cached = localByProgressKey[progressKey]) + val existing = merged[progressKey] + if (existing == null || candidate.isFresherThan(existing)) { + merged[progressKey] = candidate + } + } - localByVideoId.forEach { (videoId, localEntry) -> - val remoteEntry = merged[videoId] - val shouldPreserve = shouldPreserveLocalWatchProgressEntry( - localEntry = localEntry, - lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, - pullStartedEpochMs = pullStartedEpochMs, - ) - if (!shouldPreserve) return@forEach - if (remoteEntry == null || localEntry.lastUpdatedEpochMs > remoteEntry.lastUpdatedEpochMs) { - merged[videoId] = localEntry + localByProgressKey.forEach { (progressKey, localEntry) -> + val remoteEntry = merged[progressKey] + if (progressKey !in effectiveDirtyKeys) return@forEach + if (remoteEntry == null || localEntry.isFresherThan(remoteEntry)) { + merged[progressKey] = localEntry } } return merged } - internal fun shouldPreserveLocalWatchProgressEntry( - localEntry: WatchProgressEntry, - lastSuccessfulPushEpochMs: Long, - pullStartedEpochMs: Long, - ): Boolean { - val updatedAt = localEntry.lastUpdatedEpochMs - val wasUpdatedAfterLastPush = - lastSuccessfulPushEpochMs <= 0L || updatedAt > lastSuccessfulPushEpochMs - val wasUpdatedDuringPull = pullStartedEpochMs > 0L && updatedAt >= pullStartedEpochMs - return wasUpdatedAfterLastPush || wasUpdatedDuringPull - } - private fun retryMetadataResolutionWhenAddonMetaProvidersReady(state: AddonsUiState) { if (!hasLoaded || shouldUseTraktProgress()) return @@ -688,18 +893,25 @@ object WatchProgressRepository { if (!readiness.isReady) return val fingerprint = readiness.fingerprint - if (fingerprint == lastAddonMetadataReadyFingerprint) return - lastAddonMetadataReadyFingerprint = fingerprint - - if (metadataResolutionJob?.isActive == true) return + if (!metadataResolutionRetryCoordinator.requestForProviders(fingerprint)) return resolveRemoteMetadata() } + private fun cancelMetadataResolution(resetProviderHistory: Boolean) { + if (resetProviderHistory) { + metadataResolutionRetryCoordinator.reset() + } else { + metadataResolutionRetryCoordinator.invalidateActiveResolution() + } + metadataResolutionJob?.cancel() + metadataResolutionJob = null + } + private fun resolveRemoteMetadata() { val targetProfileId = currentProfileId val targetGeneration = profileGeneration val missingMetadataEntries = localEntriesSnapshot() - .filter { it.poster.isNullOrBlank() || it.background.isNullOrBlank() } + .filter(WatchProgressEntry::needsRemoteMetadataEnrichment) val entriesToResolve = missingMetadataEntries.continueWatchingEntries( limit = WATCH_PROGRESS_METADATA_RESOLUTION_LIMIT, ) @@ -708,75 +920,75 @@ object WatchProgressRepository { if (needsResolution.isEmpty()) return + val providersAtStart = AddonRepository.uiState.value.metadataProviderReadiness() + val resolutionGeneration = metadataResolutionRetryCoordinator.beginResolution( + providerFingerprint = providersAtStart.fingerprint.takeIf { providersAtStart.isReady }, + ) metadataResolutionJob?.cancel() - metadataResolutionJob = syncScope.launch { - val providerReadiness = awaitReadyMetadataProviders() ?: return@launch - if (!isActiveOperation(targetProfileId, targetGeneration)) return@launch - lastAddonMetadataReadyFingerprint = providerReadiness.fingerprint - - val supportedNeedsResolution = needsResolution.filter { (key, _) -> - val (metaId, metaType) = key - providerReadiness.providers.any { provider -> - provider.supportsMetaRequest(type = metaType, id = metaId) - } - } - if (supportedNeedsResolution.isEmpty()) return@launch - - val semaphore = Semaphore(WATCH_PROGRESS_METADATA_RESOLUTION_CONCURRENCY) - val resolutionResults = Channel(Channel.UNLIMITED) - supportedNeedsResolution.forEach { (key, entries) -> - launch { - val result = semaphore.withPermit { - fetchRemoteMetadataGroup(key = key, entries = entries) - } - resolutionResults.send(result) - } - } - - var resolvedEntries = 0 - repeat(supportedNeedsResolution.size) { - val result = resolutionResults.receive() - ensureActive() + metadataResolutionJob = syncScope.launch(start = CoroutineStart.LAZY) { + try { if (!isActiveOperation(targetProfileId, targetGeneration)) return@launch - val meta = result.meta ?: return@repeat - - var appliedEntries = 0 - for (entry in result.entries) { - val current = localEntry(entry.videoId) ?: continue - val episodeVideo = if (current.seasonNumber != null && current.episodeNumber != null) { - meta.videos.find { v -> - v.season == current.seasonNumber && v.episode == current.episodeNumber - } - } else null - - upsertLocalEntry( - current.copy( - title = meta.name, - poster = meta.poster, - background = meta.background, - logo = meta.logo, - episodeTitle = episodeVideo?.title ?: current.episodeTitle, - episodeThumbnail = episodeVideo?.thumbnail ?: current.episodeThumbnail, - pauseDescription = episodeVideo?.overview - ?: meta.description - ?: current.pauseDescription, - ), + AddonRepository.initialize() + val providerReadiness = AddonRepository.uiState.value.metadataProviderReadiness() + if (providerReadiness.isReady) { + metadataResolutionRetryCoordinator.providersObservedBeforeFetch( + resolutionGeneration = resolutionGeneration, + providerFingerprint = providerReadiness.fingerprint, ) - appliedEntries += 1 } - if (appliedEntries == 0) return@repeat - - resolvedEntries += appliedEntries - - if (isActiveOperation(targetProfileId, targetGeneration)) { - publish() + val semaphore = Semaphore(WATCH_PROGRESS_METADATA_RESOLUTION_CONCURRENCY) + val resolutionResults = Channel(Channel.UNLIMITED) + needsResolution.forEach { (key, entries) -> + launch { + val result = semaphore.withPermit { + fetchRemoteMetadataGroup(key = key, entries = entries) + } + resolutionResults.send(result) + } + } + + var resolvedEntries = 0 + repeat(needsResolution.size) { + val result = resolutionResults.receive() + ensureActive() + if (!isActiveOperation(targetProfileId, targetGeneration)) return@launch + val meta = result.meta + if (meta == null) { + return@repeat + } + + var appliedEntries = 0 + for (entry in result.entries) { + val current = localEntry(entry.resolvedProgressKey()) ?: continue + val enriched = enrichWatchProgressEntry(current = current, meta = meta) + if (enriched == current) continue + upsertLocalEntry(enriched) + appliedEntries += 1 + } + if (appliedEntries == 0) return@repeat + + resolvedEntries += appliedEntries + + if (isActiveOperation(targetProfileId, targetGeneration)) { + publish() + } + } + resolutionResults.close() + if (resolvedEntries > 0 && isActiveOperation(targetProfileId, targetGeneration)) { + persist() + } + } finally { + val currentReadiness = AddonRepository.uiState.value.metadataProviderReadiness() + val shouldRetry = metadataResolutionRetryCoordinator.finishResolution( + resolutionGeneration = resolutionGeneration, + currentProviderFingerprint = currentReadiness.fingerprint.takeIf { currentReadiness.isReady }, + ) + if (shouldRetry && hasLoaded && !shouldUseTraktProgress()) { + resolveRemoteMetadata() } - } - resolutionResults.close() - if (resolvedEntries > 0 && isActiveOperation(targetProfileId, targetGeneration)) { - persist() } } + metadataResolutionJob?.start() } private suspend fun fetchRemoteMetadataGroup( @@ -784,34 +996,28 @@ object WatchProgressRepository { entries: List, ): RemoteMetadataResolutionResult { val (metaId, metaType) = key - val meta = try { - MetaDetailsRepository.fetch(metaType, metaId) - } catch (error: CancellationException) { - throw error - } catch (_: Throwable) { - null + var meta: MetaDetails? = null + for (attempt in 1..WATCH_PROGRESS_METADATA_FETCH_ATTEMPTS) { + if (attempt > 1) { + val retryDelayMs = WATCH_PROGRESS_METADATA_RETRY_BASE_DELAY_MS * + (1L shl (attempt - 2)) + delay(retryDelayMs) + } + meta = try { + MetaDetailsRepository.fetch(metaType, metaId) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + null + } + if (meta != null) break } return RemoteMetadataResolutionResult( - key = key, entries = entries, meta = meta, ) } - private suspend fun awaitReadyMetadataProviders(): MetadataProviderReadiness? { - val current = AddonRepository.uiState.value.metadataProviderReadiness() - if (current.isReady) return current - if (current.isSettledWithoutProviders) return null - - val settled = withTimeoutOrNull(30_000L) { - AddonRepository.uiState.first { state -> - val readiness = state.metadataProviderReadiness() - readiness.isReady || readiness.isSettledWithoutProviders - }.metadataProviderReadiness() - } - return settled?.takeIf { it.isReady } - } - fun upsertPlaybackProgress( session: WatchProgressPlaybackSession, snapshot: PlayerPlaybackSnapshot, @@ -830,19 +1036,39 @@ object WatchProgressRepository { upsert(session = session, snapshot = snapshot, persist = true, syncRemote = syncRemote) } - fun clearProgress(videoId: String) { - clearProgress(listOf(videoId)) + fun clearProgress(videoId: String, parentMetaId: String? = null) { + clearProgress(videoIds = listOf(videoId), parentMetaId = parentMetaId) } - fun clearProgress(videoIds: Collection) { + fun clearProgress( + videoIds: Collection, + parentMetaId: String? = null, + ) { ensureLoaded() if (videoIds.isEmpty()) return val useTraktProgress = shouldUseTraktProgress() if (useTraktProgress) { - val entriesToRemove = currentEntries().filter { entry -> entry.videoId in videoIds } + val entriesToRemove = currentEntries().filter { entry -> + entry.videoId in videoIds && + (parentMetaId == null || entry.parentMetaId == parentMetaId) + } val locallyRemovedEntries = removeStoredLocalEntries(entriesToRemove) - videoIds.forEach(TraktProgressRepository::applyOptimisticRemoval) + if (parentMetaId == null) { + videoIds.forEach(TraktProgressRepository::applyOptimisticRemoval) + } else { + entriesToRemove + .distinctBy { entry -> + Triple(entry.parentMetaId, entry.seasonNumber, entry.episodeNumber) + } + .forEach { entry -> + TraktProgressRepository.applyOptimisticRemoval( + contentId = entry.parentMetaId, + seasonNumber = entry.seasonNumber, + episodeNumber = entry.episodeNumber, + ) + } + } if (locallyRemovedEntries.isNotEmpty()) { persist() } @@ -867,9 +1093,10 @@ object WatchProgressRepository { return } - val removedEntries = videoIds.mapNotNull { videoId -> - removeLocalEntry(videoId) - } + val removedEntries = removeLocalEntriesForVideoIds( + videoIds = videoIds, + parentMetaId = parentMetaId, + ) if (removedEntries.isNotEmpty()) { publish() persist() @@ -929,20 +1156,30 @@ object WatchProgressRepository { } entriesToRemove.forEach { entry -> - removeLocalEntry(entry.videoId) + removeLocalEntry(entry.resolvedProgressKey()) } publish() persist() pushDeleteToServer(entriesToRemove) } - fun progressForVideo(videoId: String): WatchProgressEntry? { + fun progressForVideo( + videoId: String, + parentMetaId: String? = null, + seasonNumber: Int? = null, + episodeNumber: Int? = null, + ): WatchProgressEntry? { ensureLoaded() return if (shouldUseTraktProgress()) { TraktProgressRepository.uiState.value.entries } else { localEntriesSnapshot() - }.firstOrNull { it.videoId == videoId } + }.resolveProgressForVideo( + videoId = videoId, + parentMetaId = parentMetaId, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + ) } fun resumeEntryForSeries(metaId: String): WatchProgressEntry? { @@ -1000,7 +1237,7 @@ object WatchProgressRepository { session.parentMetaId } - val entry = WatchProgressEntry( + val candidateEntry = WatchProgressEntry( contentType = session.contentType, parentMetaId = effectiveParentMetaId, parentMetaType = session.parentMetaType, @@ -1026,8 +1263,10 @@ object WatchProgressRepository { ).normalizedCompletion() if (targetProfileId != currentProfileId || ProfileRepository.activeProfileId != targetProfileId) { - if (persist) { - upsertStoredProfileProgress(profileId = targetProfileId, entry = entry) + val entry = if (persist) { + upsertStoredProfileProgress(profileId = targetProfileId, entry = candidateEntry) + } else { + resolveStoredProfileProgressIdentity(profileId = targetProfileId, entry = candidateEntry) } if (syncRemote) { pushScrobbleToServer(entry = entry, profileId = targetProfileId) @@ -1035,17 +1274,20 @@ object WatchProgressRepository { return } + val entry = localEntriesSnapshot().resolveIdentityForUpsert(candidateEntry) + if (entry.parentMetaType.equals("series", ignoreCase = true)) { ContinueWatchingPreferencesRepository.removeDismissedNextUpKeysForContent(entry.parentMetaId) } upsertLocalEntry(entry) + markProgressDirty(entry) if (useTraktProgress) { TraktProgressRepository.applyOptimisticProgress(entry) } publish() if (persist) persist() - if (entry.poster.isNullOrBlank() || entry.background.isNullOrBlank()) { + if (entry.needsRemoteMetadataEnrichment()) { resolveRemoteMetadata() } if (syncRemote) { @@ -1056,15 +1298,20 @@ object WatchProgressRepository { } } - private fun upsertStoredProfileProgress(profileId: Int, entry: WatchProgressEntry) { + private fun upsertStoredProfileProgress( + profileId: Int, + entry: WatchProgressEntry, + ): WatchProgressEntry { val payload = WatchProgressStorage.loadPayload(profileId).orEmpty().trim() val storedPayload = if (payload.isNotEmpty()) { WatchProgressCodec.decodePayload(payload) } else { StoredWatchProgressPayload() } + val resolvedEntry = storedPayload.entries.resolveIdentityForUpsert(entry) + val progressKey = resolvedEntry.resolvedProgressKey() val updatedEntries = storedPayload.entries - .filterNot { it.videoId == entry.videoId } + entry + .filterNot { it.resolvedProgressKey() == progressKey } + resolvedEntry WatchProgressStorage.savePayload( profileId, WatchProgressCodec.encodePayload( @@ -1072,8 +1319,23 @@ object WatchProgressRepository { lastSuccessfulPushEpochMs = storedPayload.lastSuccessfulPushEpochMs, deltaCursorEventId = storedPayload.deltaCursorEventId, deltaInitialized = storedPayload.deltaInitialized, + dirtyProgressKeys = storedPayload.dirtyProgressKeys + progressKey, ), ) + return resolvedEntry + } + + private fun resolveStoredProfileProgressIdentity( + profileId: Int, + entry: WatchProgressEntry, + ): WatchProgressEntry { + val payload = WatchProgressStorage.loadPayload(profileId).orEmpty().trim() + val storedEntries = if (payload.isEmpty()) { + emptyList() + } else { + WatchProgressCodec.decodePayload(payload).entries + } + return storedEntries.resolveIdentityForUpsert(entry) } private fun pushScrobbleToServer(entry: WatchProgressEntry, profileId: Int) { @@ -1111,7 +1373,7 @@ object WatchProgressRepository { val hasLoadedRemoteProgress = if (shouldUseTraktProgress()) { TraktProgressRepository.uiState.value.hasLoadedRemoteProgress } else { - hasLoaded + hasLoadedNuvioRemoteProgress } _uiState.value = WatchProgressUiState( entries = sortedEntries, @@ -1127,6 +1389,7 @@ object WatchProgressRepository { lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, deltaCursorEventId = deltaCursorEventId, deltaInitialized = deltaInitialized, + dirtyProgressKeys = dirtyProgressKeysSnapshot(), ), ) } @@ -1136,15 +1399,71 @@ object WatchProgressRepository { operationGeneration: Long?, entries: Collection, ) { - if (profileId != currentProfileId || operationGeneration != profileGeneration) return + if (profileId != currentProfileId) { + acknowledgeStoredProfilePush(profileId = profileId, pushedEntries = entries) + return + } + if (operationGeneration != profileGeneration) return + val dirtyChanged = acknowledgeCurrentProfilePush(entries) val latestPushed = entries .asSequence() .map { entry -> entry.lastUpdatedEpochMs } .maxOrNull() - ?: return - if (latestPushed <= lastSuccessfulPushEpochMs) return - lastSuccessfulPushEpochMs = latestPushed - persist() + ?: 0L + val watermarkChanged = latestPushed > lastSuccessfulPushEpochMs + if (watermarkChanged) { + lastSuccessfulPushEpochMs = latestPushed + } + if (dirtyChanged || watermarkChanged) persist() + } + + private fun acknowledgeCurrentProfilePush(entries: Collection): Boolean = + synchronized(entriesLock) { + var changed = false + entries.forEach { pushed -> + val key = pushed.resolvedProgressKey() + val current = entriesByProgressKey[key] + if ( + (current == null || current.lastUpdatedEpochMs <= pushed.lastUpdatedEpochMs) && + dirtyProgressKeys.remove(key) + ) { + changed = true + } + } + changed + } + + private fun acknowledgeStoredProfilePush( + profileId: Int, + pushedEntries: Collection, + ) { + val payload = WatchProgressStorage.loadPayload(profileId).orEmpty().trim() + if (payload.isEmpty()) return + val storedPayload = WatchProgressCodec.decodePayload(payload) + val storedByKey = storedPayload.entries.newestByProgressKey() + val acknowledgedKeys = pushedEntries.mapNotNullTo(mutableSetOf()) { pushed -> + val key = pushed.resolvedProgressKey() + val current = storedByKey[key] + key.takeIf { current == null || current.lastUpdatedEpochMs <= pushed.lastUpdatedEpochMs } + } + val remainingDirtyKeys = storedPayload.dirtyProgressKeys - acknowledgedKeys + val latestPushed = pushedEntries.maxOfOrNull(WatchProgressEntry::lastUpdatedEpochMs) ?: 0L + if ( + remainingDirtyKeys == storedPayload.dirtyProgressKeys && + latestPushed <= storedPayload.lastSuccessfulPushEpochMs + ) { + return + } + WatchProgressStorage.savePayload( + profileId, + WatchProgressCodec.encodePayload( + entries = storedPayload.entries, + lastSuccessfulPushEpochMs = maxOf(storedPayload.lastSuccessfulPushEpochMs, latestPushed), + deltaCursorEventId = storedPayload.deltaCursorEventId, + deltaInitialized = storedPayload.deltaInitialized, + dirtyProgressKeys = remainingDirtyKeys, + ), + ) } private fun shouldUseTraktProgress(): Boolean = @@ -1164,9 +1483,19 @@ object WatchProgressRepository { private fun removeStoredLocalEntries(entries: Collection): List = synchronized(entriesLock) { - entries.mapNotNull { entry -> - entriesByVideoId.remove(entry.videoId) - } + val targetKeys = entries.mapTo(mutableSetOf()) { entry -> entry.resolvedProgressKey() } + val keysToRemove = entriesByProgressKey + .filterValues { localEntry -> + localEntry.resolvedProgressKey() in targetKeys || entries.any { target -> + localEntry.parentMetaId == target.parentMetaId && + localEntry.seasonNumber == target.seasonNumber && + localEntry.episodeNumber == target.episodeNumber + } + } + .keys + .toList() + dirtyProgressKeys.removeAll(keysToRemove.toSet()) + keysToRemove.mapNotNull(entriesByProgressKey::remove) } private fun currentEntries(): List { @@ -1181,10 +1510,10 @@ object WatchProgressRepository { if (localNonTraktItems.isEmpty()) { traktItems } else { - val traktKeys = traktItems.map { it.videoId }.toSet() + val traktKeys = traktItems.mapTo(mutableSetOf()) { entry -> entry.resolvedProgressKey() } val merged = traktItems.toMutableList() localNonTraktItems.forEach { localItem -> - if (localItem.videoId !in traktKeys) { + if (localItem.resolvedProgressKey() !in traktKeys) { merged.add(localItem) } } @@ -1197,48 +1526,129 @@ object WatchProgressRepository { private fun localEntriesSnapshot(): List = synchronized(entriesLock) { - entriesByVideoId.values.toList() + entriesByProgressKey.values.toList() } - private fun localEntry(videoId: String): WatchProgressEntry? = + private fun localEntry(progressKey: String): WatchProgressEntry? = synchronized(entriesLock) { - entriesByVideoId[videoId] + entriesByProgressKey[progressKey] } private fun localEntryCount(): Int = synchronized(entriesLock) { - entriesByVideoId.size + entriesByProgressKey.size } private fun clearLocalEntries() { synchronized(entriesLock) { - entriesByVideoId.clear() + entriesByProgressKey.clear() + dirtyProgressKeys.clear() + } + } + + private fun dirtyProgressKeysSnapshot(): Set = + synchronized(entriesLock) { + dirtyProgressKeys.toSet() + } + + private fun replaceDirtyProgressKeys(keys: Collection) { + synchronized(entriesLock) { + dirtyProgressKeys = keys + .filterTo(mutableSetOf()) { key -> key in entriesByProgressKey } + } + } + + private fun markProgressDirty(entry: WatchProgressEntry) { + synchronized(entriesLock) { + dirtyProgressKeys += entry.resolvedProgressKey() + } + } + + private fun clearProgressDirty(progressKey: String) { + synchronized(entriesLock) { + dirtyProgressKeys -= progressKey + } + } + + private fun migrateDirtyProgressKeys(migrations: Map) { + if (migrations.isEmpty()) return + synchronized(entriesLock) { + migrations.forEach { (oldKey, newKey) -> + if (dirtyProgressKeys.remove(oldKey)) { + dirtyProgressKeys += newKey + } + } + } + } + + private fun acknowledgeDirtyProgressFromSnapshot( + serverEntries: Collection, + localEntriesBeforeApply: Collection, + dirtyKeysBeforeApply: Set, + ) { + if (dirtyKeysBeforeApply.isEmpty()) return + val localByKey = localEntriesBeforeApply.newestByProgressKey() + val remoteByKey = linkedMapOf() + serverEntries.forEach { record -> + val key = record.resolvedProgressKey() + val candidate = record.toWatchProgressEntry(cached = localByKey[key]) + val existing = remoteByKey[key] + if (existing == null || candidate.isFresherThan(existing)) { + remoteByKey[key] = candidate + } + } + synchronized(entriesLock) { + dirtyKeysBeforeApply.forEach { key -> + val local = localByKey[key] + val remote = remoteByKey[key] + if (remote != null && (local == null || !local.isFresherThan(remote))) { + dirtyProgressKeys -= key + } + } } } private fun replaceLocalEntries(entries: Collection) { synchronized(entriesLock) { - entriesByVideoId = entries - .associateBy { it.videoId } - .toMutableMap() + entriesByProgressKey = entries.newestByProgressKey().toMutableMap() } } private fun replaceLocalEntries(entries: Map) { synchronized(entriesLock) { - entriesByVideoId = entries.toMutableMap() + entriesByProgressKey = entries.values.newestByProgressKey().toMutableMap() } } private fun upsertLocalEntry(entry: WatchProgressEntry) { synchronized(entriesLock) { - entriesByVideoId[entry.videoId] = entry + val resolvedEntry = entry.withResolvedProgressKey() + entriesByProgressKey[resolvedEntry.resolvedProgressKey()] = resolvedEntry } } - private fun removeLocalEntry(videoId: String): WatchProgressEntry? = + private fun removeLocalEntry(progressKey: String): WatchProgressEntry? = synchronized(entriesLock) { - entriesByVideoId.remove(videoId) + dirtyProgressKeys -= progressKey + entriesByProgressKey.remove(progressKey) + } + + private fun removeLocalEntriesForVideoIds( + videoIds: Collection, + parentMetaId: String?, + ): List = + synchronized(entriesLock) { + if (videoIds.isEmpty()) return@synchronized emptyList() + val ids = videoIds.toSet() + val keysToRemove = entriesByProgressKey + .filterValues { entry -> + entry.videoId in ids && + (parentMetaId == null || entry.parentMetaId == parentMetaId) + } + .keys + .toList() + dirtyProgressKeys.removeAll(keysToRemove.toSet()) + keysToRemove.mapNotNull(entriesByProgressKey::remove) } fun isDroppedShow(contentId: String): Boolean { @@ -1252,17 +1662,10 @@ object WatchProgressRepository { .filter { manifest -> manifest.hasMetaResource() } return MetadataProviderReadiness( providers = providers, - isRefreshing = enabled.any { addon -> addon.isRefreshing }, ) } private fun AddonManifest.hasMetaResource(): Boolean = resources.any { resource -> resource.name == "meta" } - private fun AddonManifest.supportsMetaRequest(type: String, id: String): Boolean = - resources.any { resource -> - resource.name == "meta" && - resource.types.contains(type) && - (resource.idPrefixes.isEmpty() || resource.idPrefixes.any { prefix -> id.startsWith(prefix) }) - } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt index dfd1cd8d..f58b7807 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt @@ -5,6 +5,7 @@ import com.nuvio.app.features.watching.domain.WatchingContentRef import com.nuvio.app.features.watching.domain.WatchingProgressRecord import com.nuvio.app.features.watching.domain.continueWatchingProgressEntries import com.nuvio.app.features.watching.domain.isProgressComplete +import com.nuvio.app.features.watching.domain.isSeriesLikeWatchingContentType import com.nuvio.app.features.watching.domain.resumeProgressForSeries import com.nuvio.app.features.watching.domain.shouldStoreProgress import kotlinx.serialization.Serializable @@ -21,6 +22,7 @@ internal data class StoredWatchProgressPayload( val lastSuccessfulPushEpochMs: Long = 0L, val deltaCursorEventId: Long = 0L, val deltaInitialized: Boolean = false, + val dirtyProgressKeys: Set = emptySet(), ) internal object WatchProgressCodec { @@ -35,8 +37,16 @@ internal object WatchProgressCodec { fun decodePayload(payload: String): StoredWatchProgressPayload = runCatching { json.decodeFromString(payload).let { storedPayload -> + val migratedEntries = storedPayload.entries + .map { entry -> entry.normalizedCompletion().withResolvedProgressKey() } + .newestByProgressKey() + .values + .sortedWith(watchProgressEntryFreshnessComparator.reversed()) storedPayload.copy( - entries = storedPayload.entries.map(WatchProgressEntry::normalizedCompletion), + entries = migratedEntries, + dirtyProgressKeys = storedPayload.dirtyProgressKeys.intersect( + migratedEntries.mapTo(mutableSetOf()) { entry -> entry.resolvedProgressKey() }, + ), ) } }.getOrDefault(StoredWatchProgressPayload()) @@ -54,13 +64,18 @@ internal object WatchProgressCodec { lastSuccessfulPushEpochMs: Long, deltaCursorEventId: Long, deltaInitialized: Boolean, + dirtyProgressKeys: Set = emptySet(), ): String = json.encodeToString( StoredWatchProgressPayload( - entries = entries.toList().sortedByDescending { it.lastUpdatedEpochMs }, + entries = entries + .newestByProgressKey() + .values + .sortedWith(watchProgressEntryFreshnessComparator.reversed()), lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, deltaCursorEventId = deltaCursorEventId, deltaInitialized = deltaInitialized, + dirtyProgressKeys = dirtyProgressKeys, ), ) } @@ -80,26 +95,41 @@ internal fun isWatchProgressComplete( isEnded = isEnded, ) -internal fun List.resumeEntryForSeries(metaId: String): WatchProgressEntry? = - firstOrNull { entry -> entry.parentMetaId == metaId }?.let { seed -> - resumeProgressForSeries( - content = WatchingContentRef(type = seed.parentMetaType, id = metaId), - progressRecords = map(WatchProgressEntry::toDomainProgressRecord), - )?.let { record -> - firstOrNull { entry -> entry.videoId == record.videoId } - } +internal fun List.resumeEntryForSeries(metaId: String): WatchProgressEntry? { + val candidates = newestByProgressKey().values.toList() + val normalizedMetaId = metaId.trim() + if (normalizedMetaId.isEmpty()) return null + val contentCandidates = candidates.filter { entry -> + entry.parentMetaId.trim() == normalizedMetaId } + val seed = contentCandidates.firstOrNull() ?: return null + val hasSeriesCandidate = contentCandidates.any { entry -> + entry.parentMetaType.isSeriesLikeWatchingContentType() + } + val requestedType = if (hasSeriesCandidate) "series" else seed.parentMetaType + + return resumeProgressForSeries( + content = WatchingContentRef(type = requestedType, id = normalizedMetaId), + progressRecords = candidates.map(WatchProgressEntry::toDomainProgressRecord), + )?.let { record -> + candidates.firstOrNull { entry -> entry.resolvedProgressKey() == record.identityKey } + } +} internal fun List.continueWatchingEntries( limit: Int = ContinueWatchingLimit, ): List { - val inProgressEntries = filter { entry -> entry.shouldTreatAsInProgressForContinueWatching() } + val selectionEntries = filter { entry -> + entry.isEffectivelyCompleted || entry.shouldTreatAsInProgressForContinueWatching() + }.newestByProgressKey().values.toList() val domainEntries = continueWatchingProgressEntries( - progressRecords = inProgressEntries.map(WatchProgressEntry::toDomainProgressRecord), + progressRecords = selectionEntries.map(WatchProgressEntry::toDomainProgressRecord), limit = limit, ) - val ids = domainEntries.map { record -> record.videoId }.toSet() - return inProgressEntries.filter { entry -> entry.videoId in ids } + val identityKeys = domainEntries.map { record -> record.identityKey }.toSet() + return selectionEntries + .filter { entry -> entry.resolvedProgressKey() in identityKeys } + .filter { entry -> entry.shouldTreatAsInProgressForContinueWatching() } .sortedByDescending { it.lastUpdatedEpochMs } } @@ -163,17 +193,18 @@ internal fun isMalformedNextUpSeedContentId(contentId: String?): Boolean { private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord = normalizedCompletion().let { entry -> WatchingProgressRecord( - content = WatchingContentRef( - type = entry.parentMetaType, - id = entry.parentMetaId, - ), - videoId = entry.videoId, - seasonNumber = entry.seasonNumber, - episodeNumber = entry.episodeNumber, - lastUpdatedEpochMs = entry.lastUpdatedEpochMs, - lastPositionMs = entry.lastPositionMs, - isCompleted = entry.isCompleted, - episodeTitle = entry.episodeTitle, - episodeThumbnail = entry.episodeThumbnail, - ) + content = WatchingContentRef( + type = entry.parentMetaType, + id = entry.parentMetaId, + ), + videoId = entry.videoId, + seasonNumber = entry.seasonNumber, + episodeNumber = entry.episodeNumber, + lastUpdatedEpochMs = entry.lastUpdatedEpochMs, + lastPositionMs = entry.lastPositionMs, + isCompleted = entry.isEffectivelyCompleted, + episodeTitle = entry.episodeTitle, + episodeThumbnail = entry.episodeThumbnail, + identityKey = entry.resolvedProgressKey(), + ) } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt index 987cd9bc..20df1e2e 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt @@ -7,14 +7,24 @@ import com.nuvio.app.features.cloud.CloudLibraryProviderState import com.nuvio.app.features.cloud.CloudLibraryUiState import com.nuvio.app.features.cloud.playbackVideoId import com.nuvio.app.features.debrid.DebridProviders +import com.nuvio.app.features.watchprogress.CachedInProgressItem +import com.nuvio.app.features.watchprogress.CachedNextUpItem import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktHistory +import com.nuvio.app.features.watchprogress.nextUpDismissKey +import com.nuvio.app.features.watchprogress.parseReleaseDateToEpochMs +import com.nuvio.app.features.watchprogress.resolvedProgressKey +import com.nuvio.app.features.watchprogress.toContinueWatchingItem import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL import com.nuvio.app.features.trakt.WatchProgressSource import com.nuvio.app.features.watching.domain.WatchingContentRef +import kotlinx.serialization.json.Json import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class HomeScreenTest { @@ -197,6 +207,13 @@ class HomeScreenTest { videoId = "show:1:4", title = "Show", lastUpdatedEpochMs = 500L, + ).copy( + logo = " ", + poster = "", + background = "\t", + episodeTitle = " ", + episodeThumbnail = "", + pauseDescription = " ", ) val cached = ContinueWatchingItem( parentMetaId = "show", @@ -221,13 +238,133 @@ class HomeScreenTest { val result = buildHomeContinueWatchingItems( visibleEntries = listOf(progress), - cachedInProgressByVideoId = mapOf(progress.videoId to cached), + cachedInProgressByProgressKey = mapOf(progress.resolvedProgressKey() to cached), nextUpItemsBySeries = emptyMap(), ) assertEquals("https://example.test/cached.jpg", result.single().imageUrl) assertEquals("https://example.test/logo.png", result.single().logo) + assertEquals("https://example.test/poster.jpg", result.single().poster) + assertEquals("https://example.test/backdrop.jpg", result.single().background) + assertEquals("Cached Episode", result.single().episodeTitle) assertEquals("https://example.test/thumb.jpg", result.single().episodeThumbnail) + assertEquals("Cached description", result.single().pauseDescription) + } + + @Test + fun `continue watching artwork selection skips blank values`() { + val progress = progressEntry( + videoId = "show:1:4", + title = "Show", + lastUpdatedEpochMs = 500L, + ).copy( + logo = " ", + poster = "https://example.test/poster.jpg", + background = "\t", + episodeThumbnail = "", + ) + + val item = progress.toContinueWatchingItem() + + assertEquals("https://example.test/poster.jpg", item.imageUrl) + assertEquals("https://example.test/poster.jpg", item.poster) + assertNull(item.logo) + assertNull(item.background) + assertNull(item.episodeThumbnail) + } + + @Test + fun `home in progress snapshot preserves cached metadata through serialization round trip`() { + val progress = progressEntry( + videoId = "show:1:4", + title = " ", + lastUpdatedEpochMs = 500L, + ).copy( + progressKey = "opaque-progress-key", + logo = "", + poster = " ", + background = "\t", + episodeTitle = "", + episodeThumbnail = " ", + pauseDescription = "", + ) + val cached = CachedInProgressItem( + contentId = "show", + contentType = "series", + name = "Cached Show", + poster = "https://example.test/poster.jpg", + backdrop = "https://example.test/backdrop.jpg", + logo = "https://example.test/logo.png", + videoId = "show:1:4", + season = 1, + episode = 4, + episodeTitle = "Cached Episode", + episodeThumbnail = "https://example.test/thumb.jpg", + pauseDescription = "Cached description", + position = 10L, + duration = 20L, + lastWatched = 30L, + progressPercent = 50f, + progressKey = "opaque-progress-key", + ) + + val snapshot = buildHomeInProgressCacheSnapshot( + visibleEntries = listOf(progress), + cachedEntries = listOf(cached), + ).single() + val encoded = Json.encodeToString(CachedInProgressItem.serializer(), snapshot) + val restored = Json.decodeFromString(CachedInProgressItem.serializer(), encoded) + + assertEquals("Cached Show", restored.name) + assertEquals("https://example.test/poster.jpg", restored.poster) + assertEquals("https://example.test/backdrop.jpg", restored.backdrop) + assertEquals("https://example.test/logo.png", restored.logo) + assertEquals("Cached Episode", restored.episodeTitle) + assertEquals("https://example.test/thumb.jpg", restored.episodeThumbnail) + assertEquals("Cached description", restored.pauseDescription) + assertEquals(progress.lastPositionMs, restored.position) + assertEquals(progress.durationMs, restored.duration) + assertEquals(progress.lastUpdatedEpochMs, restored.lastWatched) + assertEquals("opaque-progress-key", restored.progressKey) + assertNull(restored.progressPercent) + } + + @Test + fun `cached artwork does not cross aliases that share a playback video id`() { + val firstProgress = progressEntry( + videoId = "shared-video", + title = "First", + lastUpdatedEpochMs = 500L, + ).copy( + parentMetaId = "show-a", + progressKey = "opaque-a", + ) + val secondProgress = firstProgress.copy( + parentMetaId = "show-b", + title = "Second", + lastUpdatedEpochMs = 400L, + progressKey = "opaque-b", + ) + val firstCached = firstProgress.toContinueWatchingItem().copy( + imageUrl = "https://example.test/a.jpg", + poster = "https://example.test/a.jpg", + ) + val secondCached = secondProgress.toContinueWatchingItem().copy( + imageUrl = "https://example.test/b.jpg", + poster = "https://example.test/b.jpg", + ) + + val result = buildHomeContinueWatchingItems( + visibleEntries = listOf(firstProgress, secondProgress), + cachedInProgressByProgressKey = mapOf( + "opaque-a" to firstCached, + "opaque-b" to secondCached, + ), + nextUpItemsBySeries = emptyMap(), + ).associateBy(ContinueWatchingItem::parentMetaId) + + assertEquals("https://example.test/a.jpg", result.getValue("show-a").imageUrl) + assertEquals("https://example.test/b.jpg", result.getValue("show-b").imageUrl) } @Test @@ -352,6 +489,34 @@ class HomeScreenTest { assertEquals(14, result.single().episodeNumber) } + @Test + fun `Trakt next up seeds ignore watched items from the separate watched sync`() { + val traktProgress = progressEntry( + videoId = "show:1:2", + title = "Show", + seasonNumber = 1, + episodeNumber = 2, + lastUpdatedEpochMs = 2_000L, + isCompleted = true, + ).copy(source = WatchProgressSourceTraktHistory) + val watchedItem = watchedItem( + id = "other-show", + season = 3, + episode = 8, + markedAtEpochMs = 3_000L, + ) + + val result = buildHomeNextUpSeedCandidates( + progressEntries = listOf(traktProgress), + watchedItems = listOf(watchedItem), + isTraktProgressActive = true, + preferFurthestEpisode = true, + nowEpochMs = 4_000L, + ) + + assertEquals(listOf("show"), result.map { it.content.id }) + } + @Test fun `stale live next up item is dropped when current seed advances`() { val staleNextUp = continueWatchingItem( @@ -371,6 +536,270 @@ class HomeScreenTest { assertTrue(result.isEmpty()) } + @Test + fun `home next up waits for the selected seed source before resolving or clearing cache`() { + assertFalse( + isHomeNextUpSeedSourceLoaded( + isTraktProgressActive = false, + hasLoadedRemoteProgress = false, + hasLoadedWatchedItems = true, + hasLoadedRemoteWatchedItems = true, + ), + ) + assertFalse( + isHomeNextUpSeedSourceLoaded( + isTraktProgressActive = false, + hasLoadedRemoteProgress = true, + hasLoadedWatchedItems = false, + hasLoadedRemoteWatchedItems = true, + ), + ) + assertFalse( + isHomeNextUpSeedSourceLoaded( + isTraktProgressActive = false, + hasLoadedRemoteProgress = true, + hasLoadedWatchedItems = true, + hasLoadedRemoteWatchedItems = false, + ), + ) + assertTrue( + isHomeNextUpSeedSourceLoaded( + isTraktProgressActive = false, + hasLoadedRemoteProgress = true, + hasLoadedWatchedItems = true, + hasLoadedRemoteWatchedItems = true, + ), + ) + assertTrue( + isHomeNextUpSeedSourceLoaded( + isTraktProgressActive = true, + hasLoadedRemoteProgress = true, + hasLoadedWatchedItems = false, + hasLoadedRemoteWatchedItems = false, + ), + ) + } + + @Test + fun `home next up progressively merges live results with unprocessed cache`() { + val cachedResolved = continueWatchingItem( + videoId = "show:1:2", + subtitle = "Next Up • S1E2 • Cached", + seedEpisodeNumber = 1, + imageUrl = "https://example.test/cached-show.jpg", + ) + val cachedUnprocessed = continueWatchingItem( + videoId = "deferred:1:2", + subtitle = "Next Up • S1E2 • Deferred", + seedEpisodeNumber = 1, + imageUrl = "https://example.test/cached-deferred.jpg", + ) + val liveResolved = continueWatchingItem( + videoId = "show:1:3", + subtitle = "Next Up • S1E3 • Live", + seedEpisodeNumber = 1, + ) + + val result = mergeHomeNextUpItemsWithCache( + resolvedItems = mapOf("show" to (300L to liveResolved)), + cachedItems = mapOf( + "show" to (100L to cachedResolved), + "deferred" to (90L to cachedUnprocessed), + ), + conclusivelyProcessedContentIds = setOf("show"), + ) + + assertEquals(setOf("show", "deferred"), result.keys) + assertEquals("show:1:3", result.getValue("show").second.videoId) + assertEquals("https://example.test/cached-show.jpg", result.getValue("show").second.imageUrl) + assertEquals("deferred:1:2", result.getValue("deferred").second.videoId) + } + + @Test + fun `home next up drops conclusive null while retaining transient cache`() { + val conclusive = continueWatchingItem( + videoId = "conclusive:1:2", + subtitle = "Next Up • S1E2", + ) + val transient = continueWatchingItem( + videoId = "transient:1:2", + subtitle = "Next Up • S1E2", + ) + + val result = mergeHomeNextUpItemsWithCache( + resolvedItems = emptyMap(), + cachedItems = mapOf( + "conclusive" to (100L to conclusive), + "transient" to (90L to transient), + ), + conclusivelyProcessedContentIds = setOf("conclusive"), + ) + + assertEquals(setOf("transient"), result.keys) + } + + @Test + fun `cached next up recalculates aired state from release timestamp`() { + val released = "2026-07-12T00:00:00Z" + val releaseEpochMs = requireNotNull(parseReleaseDateToEpochMs(released)) + + assertTrue( + cachedNextUpHasAired( + cached = cachedNextUpItem(released = released, hasAired = false), + nowEpochMs = releaseEpochMs + 1L, + ), + ) + assertFalse( + cachedNextUpHasAired( + cached = cachedNextUpItem(released = released, hasAired = true), + nowEpochMs = releaseEpochMs - 1L, + ), + ) + } + + @Test + fun `cached next up invalidates whenever the authoritative seed changes`() { + assertTrue( + hasHomeNextUpSeedChangedFromCache( + currentSeason = 2, + currentEpisode = 1, + cachedSeason = 1, + cachedEpisode = 10, + ), + ) + assertTrue( + hasHomeNextUpSeedChangedFromCache( + currentSeason = 1, + currentEpisode = 11, + cachedSeason = 1, + cachedEpisode = 10, + ), + ) + assertTrue( + hasHomeNextUpSeedChangedFromCache( + currentSeason = 1, + currentEpisode = 9, + cachedSeason = 1, + cachedEpisode = 10, + ), + ) + assertFalse( + hasHomeNextUpSeedChangedFromCache( + currentSeason = 1, + currentEpisode = 10, + cachedSeason = 1, + cachedEpisode = 10, + ), + ) + } + + @Test + fun `home next up does not treat placeholder cache as enriched metadata`() { + val placeholder = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + imageUrl = " ", + logo = null, + episodeThumbnail = null, + ).copy( + title = "show", + poster = "", + background = "\t", + ) + + assertFalse(hasUsableHomeNextUpMetadata(placeholder)) + } + + @Test + fun `home next up accepts cache with resolved title and artwork`() { + val enriched = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + imageUrl = "https://example.test/show.jpg", + ).copy(title = "Resolved Show") + + assertTrue(hasUsableHomeNextUpMetadata(enriched)) + } + + @Test + fun `home next up combines fresh title with cached artwork before classifying metadata`() { + val fresh = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + ).copy(title = "Fresh Show") + val cached = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + imageUrl = "https://example.test/cached-show.jpg", + ).copy(title = "show") + + val decision = classifyHomeNextUpCandidateMetadata( + freshItem = fresh, + cachedFallbackItem = cached, + dismissedNextUpKeys = emptySet(), + ) + + assertEquals(HomeNextUpCandidateMetadataOutcome.Ready, decision.outcome) + assertEquals("Fresh Show", decision.item.title) + assertEquals("https://example.test/cached-show.jpg", decision.item.imageUrl) + } + + @Test + fun `home next up combines fresh artwork with cached title before classifying metadata`() { + val fresh = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + imageUrl = "https://example.test/fresh-show.jpg", + ).copy(title = "show") + val cached = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + ).copy(title = "Cached Show") + + val decision = classifyHomeNextUpCandidateMetadata( + freshItem = fresh, + cachedFallbackItem = cached, + dismissedNextUpKeys = emptySet(), + ) + + assertEquals(HomeNextUpCandidateMetadataOutcome.Ready, decision.outcome) + assertEquals("Cached Show", decision.item.title) + assertEquals("https://example.test/fresh-show.jpg", decision.item.imageUrl) + } + + @Test + fun `home next up does not count logo alone as card artwork`() { + val logoOnly = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + logo = "https://example.test/show-logo.png", + ).copy(title = "Resolved Show") + + assertFalse(hasUsableHomeNextUpMetadata(logoOnly)) + } + + @Test + fun `dismissed placeholder next up is conclusive instead of transient`() { + val placeholder = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + seasonNumber = 1, + episodeNumber = 2, + seedSeasonNumber = 1, + seedEpisodeNumber = 1, + ).copy(title = "show") + val dismissKey = nextUpDismissKey("show", 1, 1) + + val decision = classifyHomeNextUpCandidateMetadata( + freshItem = placeholder, + cachedFallbackItem = null, + dismissedNextUpKeys = setOf(dismissKey), + ) + + assertFalse(hasUsableHomeNextUpMetadata(decision.item)) + assertEquals(HomeNextUpCandidateMetadataOutcome.Dismissed, decision.outcome) + } + private fun progressEntry( videoId: String, title: String, @@ -441,6 +870,25 @@ class HomeScreenTest { markedAtEpochMs = markedAtEpochMs, ) + private fun cachedNextUpItem( + released: String?, + hasAired: Boolean, + ): CachedNextUpItem = + CachedNextUpItem( + contentId = "show", + contentType = "series", + name = "Show", + videoId = "show:1:2", + season = 1, + episode = 2, + released = released, + hasAired = hasAired, + lastWatched = 1_000L, + sortTimestamp = 1_000L, + seedSeason = 1, + seedEpisode = 1, + ) + private fun completedSeriesCandidate(index: Int): CompletedSeriesCandidate = CompletedSeriesCandidate( content = WatchingContentRef(type = "series", id = "show-$index"), diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktProgressRemovalTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktProgressRemovalTest.kt new file mode 100644 index 00000000..b1e20c98 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktProgressRemovalTest.kt @@ -0,0 +1,53 @@ +package com.nuvio.app.features.trakt + +import com.nuvio.app.features.watchprogress.WatchProgressEntry +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TraktProgressRemovalTest { + + @Test + fun `episode removal matches only the exact logical episode`() { + val target = entry(contentId = "show", season = 2, episode = 3) + val otherEpisode = entry(contentId = "show", season = 2, episode = 4) + val otherParent = entry(contentId = "other-show", season = 2, episode = 3) + + assertTrue(target.matchesTraktRemovalContext("show", seasonNumber = 2, episodeNumber = 3)) + assertFalse(otherEpisode.matchesTraktRemovalContext("show", seasonNumber = 2, episodeNumber = 3)) + assertFalse(otherParent.matchesTraktRemovalContext("show", seasonNumber = 2, episodeNumber = 3)) + } + + @Test + fun `incomplete episode context never widens removal to the whole show`() { + val episode = entry(contentId = "show", season = 2, episode = 3) + + assertFalse(hasCompleteTraktEpisodeContext(seasonNumber = 2, episodeNumber = null)) + assertFalse(hasCompleteTraktEpisodeContext(seasonNumber = null, episodeNumber = 3)) + assertFalse(episode.matchesTraktRemovalContext("show", seasonNumber = 2, episodeNumber = null)) + assertFalse(episode.matchesTraktRemovalContext("show", seasonNumber = null, episodeNumber = 3)) + } + + @Test + fun `show removal matches only entries from the requested parent`() { + assertTrue(entry(contentId = "show").matchesTraktRemovalContext(" show ")) + assertFalse(entry(contentId = "other-show").matchesTraktRemovalContext("show")) + } + + private fun entry( + contentId: String, + season: Int = 1, + episode: Int = 1, + ): WatchProgressEntry = WatchProgressEntry( + contentType = "series", + parentMetaId = contentId, + parentMetaType = "series", + videoId = "$contentId:$season:$episode", + title = "Show", + seasonNumber = season, + episodeNumber = episode, + lastPositionMs = 100L, + durationMs = 1_000L, + lastUpdatedEpochMs = 10L, + ) +} 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 706ce6d7..1857f3d9 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 @@ -72,85 +72,67 @@ class WatchedRepositoryTest { } @Test - fun mergeWatchedItemsPreservingUnsynced_keeps_local_items_marked_after_last_push() { - val serverItem = WatchedItem( - id = "show", - type = "series", - name = "Episode 1", - season = 1, - episode = 1, - markedAtEpochMs = 1_000L, - ) - val unsyncedLocalItem = WatchedItem( - id = "show", - type = "series", - name = "Episode 2", - season = 1, - episode = 2, - markedAtEpochMs = 3_000L, + fun snapshot_dropsRemoteLoadedLocalMissingFromServerDespiteLegacyZeroPushWatermark() { + val remoteLoadedLocalItem = watchedItem(id = "remote-loaded", markedAtEpochMs = 1_000L) + + val merged = mergeWatchedSnapshot( + serverItems = emptyList(), + localItems = listOf(remoteLoadedLocalItem), + dirtyKeys = emptySet(), ) - val merged = mergeWatchedItemsPreservingUnsynced( - serverItems = listOf(serverItem), - localItems = listOf(serverItem, unsyncedLocalItem), - lastSuccessfulPushEpochMs = 2_000L, - pullStartedEpochMs = 4_000L, - ) - - assertEquals( - setOf("series:show:1:1", "series:show:1:2"), - merged.keys, - ) + assertTrue(merged.items.isEmpty()) + assertTrue(merged.dirtyKeys.isEmpty()) } @Test - fun mergeWatchedItemsPreservingUnsynced_drops_old_local_items_missing_from_server() { - val oldLocalItem = WatchedItem( - id = "show", - type = "series", - name = "Episode 1", - season = 1, - episode = 1, - markedAtEpochMs = 1_000L, - ) + fun snapshot_preservesGenuinePendingLocalMarkMissingFromServer() { + val pendingLocalItem = watchedItem(id = "pending", markedAtEpochMs = 1_000L) + val pendingKey = watchedItemKey(pendingLocalItem.type, pendingLocalItem.id) - val merged = mergeWatchedItemsPreservingUnsynced( + val merged = mergeWatchedSnapshot( serverItems = emptyList(), - localItems = listOf(oldLocalItem), - lastSuccessfulPushEpochMs = 2_000L, - pullStartedEpochMs = 4_000L, + localItems = listOf(pendingLocalItem), + dirtyKeys = setOf(pendingKey), ) - assertTrue(merged.isEmpty()) + assertEquals(mapOf(pendingKey to pendingLocalItem), merged.items) + assertEquals(setOf(pendingKey), merged.dirtyKeys) } @Test - fun mergeWatchedItemsPreservingUnsynced_keeps_local_only_item_before_first_push() { - val localOnlyItem = watchedItem(id = "local-only", markedAtEpochMs = 1_000L) + fun snapshot_acknowledgesOnlyDirtyKeyWithEqualOrNewerRemoteItem() { + val acknowledgedLocal = watchedItem(id = "acknowledged", markedAtEpochMs = 1_000L) + val stillPendingLocal = watchedItem(id = "still-pending", markedAtEpochMs = 2_000L) + val acknowledgedRemote = acknowledgedLocal.copy(name = "server copy") + val acknowledgedKey = watchedItemKey(acknowledgedLocal.type, acknowledgedLocal.id) + val stillPendingKey = watchedItemKey(stillPendingLocal.type, stillPendingLocal.id) - val merged = mergeWatchedItemsPreservingUnsynced( - serverItems = emptyList(), - localItems = listOf(localOnlyItem), - lastSuccessfulPushEpochMs = 0L, - pullStartedEpochMs = 2_000L, + val merged = mergeWatchedSnapshot( + serverItems = listOf(acknowledgedRemote), + localItems = listOf(acknowledgedLocal, stillPendingLocal), + dirtyKeys = setOf(acknowledgedKey, stillPendingKey), ) - assertEquals(listOf(localOnlyItem), merged.values.toList()) + assertEquals(acknowledgedRemote, merged.items[acknowledgedKey]) + assertEquals(stillPendingLocal, merged.items[stillPendingKey]) + assertEquals(setOf(stillPendingKey), merged.dirtyKeys) } @Test - fun traktSnapshot_drops_old_transient_items_missingFromRemote() { - val oldTraktItem = watchedItem(id = "old-trakt", markedAtEpochMs = 1_000L) + fun successfulPush_acknowledgesOnlyPushedDirtyKey() { + val pushedItem = watchedItem(id = "pushed", markedAtEpochMs = 1_000L) + val pendingItem = watchedItem(id = "pending", markedAtEpochMs = 2_000L) + val pushedKey = watchedItemKey(pushedItem.type, pushedItem.id) + val pendingKey = watchedItemKey(pendingItem.type, pendingItem.id) - val merged = mergeWatchedItemsPreservingUnsynced( - serverItems = emptyList(), - localItems = listOf(oldTraktItem), - lastSuccessfulPushEpochMs = 0L, - pullStartedEpochMs = 2_000L, - preserveWhenNoSuccessfulPush = false, + val remainingDirtyKeys = acknowledgeSuccessfulWatchedPush( + currentItems = mapOf(pushedKey to pushedItem, pendingKey to pendingItem), + dirtyKeys = setOf(pushedKey, pendingKey), + pushedItems = listOf(pushedItem), ) - assertTrue(merged.isEmpty()) + assertEquals(setOf(pendingKey), remainingDirtyKeys) } @Test diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt index e615cbc6..afcf8f73 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt @@ -28,7 +28,7 @@ class WatchingStateTest { } @Test - fun `visible continue watching keeps active resume when newer episode is completed`() { + fun `visible continue watching drops stale resume when newer episode is completed`() { val resume = entry( videoId = "show:1:4", seasonNumber = 1, @@ -52,7 +52,7 @@ class WatchingStateTest { latestCompletedBySeries = latestCompleted, ) - assertEquals(listOf("show:1:4"), result.map { it.videoId }) + assertTrue(result.isEmpty()) } @Test diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt index cedfcee4..e619ae24 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt @@ -13,6 +13,130 @@ class SeriesContinuityTest { WatchingReleasedEpisode(videoId = "ep3", seasonNumber = 1, episodeNumber = 3, title = "Episode 3", releasedDate = "2026-03-15"), ) + @Test + fun continueWatchingProgressEntries_drops_older_resume_when_latest_series_progress_is_completed() { + val result = continueWatchingProgressEntries( + progressRecords = listOf( + WatchingProgressRecord( + content = show, + videoId = "show:1:4", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 100L, + lastPositionMs = 10_000L, + ), + WatchingProgressRecord( + content = show, + videoId = "show:1:5", + seasonNumber = 1, + episodeNumber = 5, + lastUpdatedEpochMs = 200L, + isCompleted = true, + ), + ), + ) + + assertEquals(emptyList(), result) + } + + @Test + fun continueWatchingProgressEntries_keeps_newer_resume_and_independent_movies() { + val movieA = WatchingContentRef(type = "movie", id = "movie-a") + val movieB = WatchingContentRef(type = "movie", id = "movie-b") + val result = continueWatchingProgressEntries( + progressRecords = listOf( + WatchingProgressRecord( + content = show, + videoId = "show:1:4", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 100L, + isCompleted = true, + ), + WatchingProgressRecord( + content = show, + videoId = "show:1:5", + seasonNumber = 1, + episodeNumber = 5, + lastUpdatedEpochMs = 400L, + lastPositionMs = 10_000L, + ), + WatchingProgressRecord( + content = movieA, + videoId = "movie-a", + lastUpdatedEpochMs = 300L, + lastPositionMs = 10_000L, + ), + WatchingProgressRecord( + content = movieB, + videoId = "movie-b", + lastUpdatedEpochMs = 200L, + lastPositionMs = 10_000L, + ), + ), + ) + + assertEquals(listOf("show:1:5", "movie-a", "movie-b"), result.map { it.videoId }) + } + + @Test + fun resumeProgressForSeries_coalesces_series_type_variants_independent_of_input_order() { + val staleResume = WatchingProgressRecord( + content = WatchingContentRef(type = "SeRiEs", id = "show"), + videoId = "show:1:4", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 100L, + lastPositionMs = 10_000L, + ) + val completed = WatchingProgressRecord( + content = WatchingContentRef(type = "tV", id = "show"), + videoId = "show:1:5", + seasonNumber = 1, + episodeNumber = 5, + lastUpdatedEpochMs = 200L, + isCompleted = true, + ) + val inputOrders = listOf( + listOf(staleResume, completed), + listOf(completed, staleResume), + ) + + listOf("series", "TV", "Show", "TvShow").forEach { requestedType -> + inputOrders.forEach { progressRecords -> + assertNull( + resumeProgressForSeries( + content = WatchingContentRef(type = requestedType, id = "show"), + progressRecords = progressRecords, + ), + ) + } + } + } + + @Test + fun resumeProgressForSeries_keeps_non_series_content_types_exact() { + val movieResume = WatchingProgressRecord( + content = WatchingContentRef(type = "movie", id = "shared"), + videoId = "shared", + lastUpdatedEpochMs = 100L, + lastPositionMs = 10_000L, + ) + val differentlyCasedMovie = movieResume.copy( + content = WatchingContentRef(type = "Movie", id = "shared"), + lastUpdatedEpochMs = 200L, + isCompleted = true, + ) + + assertEquals( + movieResume, + resumeProgressForSeries( + content = WatchingContentRef(type = "movie", id = "shared"), + progressRecords = listOf(differentlyCasedMovie, movieResume), + ), + ) + } + @Test fun decideSeriesPrimaryAction_prefers_up_next_when_completed_is_newer_than_resume() { val action = decideSeriesPrimaryAction( 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 new file mode 100644 index 00000000..39003816 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt @@ -0,0 +1,516 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.features.details.MetaDetails +import com.nuvio.app.features.watching.sync.ProgressSyncRecord +import com.nuvio.app.features.watching.sync.ProgressDeltaEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class WatchProgressIdentityTest { + + @Test + fun `provider change during metadata batch schedules one follow up`() { + val coordinator = MetadataResolutionRetryCoordinator() + + assertTrue(coordinator.requestForProviders("provider-a")) + val firstResolution = coordinator.beginResolution("provider-a") + assertTrue(!coordinator.requestForProviders("provider-b")) + + assertTrue(coordinator.finishResolution(firstResolution, "provider-b")) + assertTrue(!coordinator.requestForProviders("provider-b")) + + val followUpResolution = coordinator.beginResolution("provider-b") + assertTrue(!coordinator.finishResolution(followUpResolution, "provider-b")) + } + + @Test + fun `provider observed before metadata fetch does not schedule redundant follow up`() { + val coordinator = MetadataResolutionRetryCoordinator() + val resolution = coordinator.beginResolution(providerFingerprint = null) + + assertTrue(!coordinator.requestForProviders("provider-a")) + coordinator.providersObservedBeforeFetch( + resolutionGeneration = resolution, + providerFingerprint = "provider-a", + ) + + assertTrue(!coordinator.finishResolution(resolution, "provider-a")) + assertTrue(!coordinator.requestForProviders("provider-a")) + } + + @Test + fun `completion from replaced metadata batch cannot schedule retry`() { + val coordinator = MetadataResolutionRetryCoordinator() + val replacedResolution = coordinator.beginResolution("provider-a") + val activeResolution = coordinator.beginResolution("provider-b") + + assertTrue(!coordinator.finishResolution(replacedResolution, "provider-c")) + assertTrue(!coordinator.finishResolution(activeResolution, "provider-b")) + } + + @Test + fun `legacy payload derives episode progress key and preserves completed position`() { + val decoded = WatchProgressCodec.decodeEntries( + """ + { + "entries": [{ + "contentType": "series", + "parentMetaId": "show", + "parentMetaType": "series", + "videoId": "show:1:2", + "title": "Show", + "seasonNumber": 1, + "episodeNumber": 2, + "lastPositionMs": 940, + "durationMs": 1000, + "lastUpdatedEpochMs": 100 + }] + } + """.trimIndent(), + ) + + assertEquals("show_s1e2", decoded.single().progressKey) + assertEquals(940L, decoded.single().lastPositionMs) + assertTrue(decoded.single().isCompleted) + } + + @Test + fun `custom progress key round trips byte for byte`() { + val customKey = " Remote/KEY:S1E2 " + val decoded = WatchProgressCodec.decodeEntries( + WatchProgressCodec.encodeEntries( + listOf(entry(progressKey = customKey)), + ), + ) + + assertEquals(customKey, decoded.single().progressKey) + assertEquals(customKey, decoded.single().resolvedProgressKey()) + } + + @Test + fun `key-only delta delete preserves its opaque identity`() { + val event = ProgressDeltaEvent( + eventId = 7L, + operation = "delete", + progressKey = " Remote/Delete-Key ", + ) + + assertEquals(" Remote/Delete-Key ", event.resolvedProgressKey()) + assertEquals("", event.videoId) + } + + @Test + fun `codec keeps aliases sharing video id and deduplicates only equal keys`() { + val sharedVideoId = "shared-video" + val olderSameKey = entry( + parentMetaId = "show-a", + videoId = sharedVideoId, + progressKey = "opaque-a", + lastUpdatedEpochMs = 10L, + lastPositionMs = 100L, + ) + val newerSameKey = olderSameKey.copy( + lastUpdatedEpochMs = 20L, + lastPositionMs = 200L, + ) + val otherAlias = entry( + parentMetaId = "show-b", + videoId = sharedVideoId, + progressKey = "opaque-b", + lastUpdatedEpochMs = 15L, + ) + + val decoded = WatchProgressCodec.decodeEntries( + WatchProgressCodec.encodeEntries(listOf(olderSameKey, otherAlias, newerSameKey)), + ) + + assertEquals(setOf("opaque-a", "opaque-b"), decoded.map { it.resolvedProgressKey() }.toSet()) + assertEquals(200L, decoded.single { it.resolvedProgressKey() == "opaque-a" }.lastPositionMs) + } + + @Test + fun `same-key completion tie is independent of input order`() { + val incomplete = entry( + progressKey = "opaque", + lastUpdatedEpochMs = 100L, + lastPositionMs = 900L, + ) + val completed = incomplete.copy(isCompleted = true) + + val forward = listOf(incomplete, completed).newestByProgressKey().getValue("opaque") + val reversed = listOf(completed, incomplete).newestByProgressKey().getValue("opaque") + + assertTrue(forward.isCompleted) + assertEquals(forward, reversed) + } + + @Test + fun `partial metadata enrichment keeps existing nonblank artwork`() { + val current = entry().copy( + title = "Existing title", + logo = "existing-logo", + poster = "existing-poster", + background = null, + ) + + val enriched = enrichWatchProgressEntry( + current = current, + meta = MetaDetails( + id = "show", + type = "series", + name = "", + logo = null, + poster = "", + background = "new-background", + ), + ) + + assertEquals("Existing title", enriched.title) + assertEquals("existing-logo", enriched.logo) + assertEquals("existing-poster", enriched.poster) + assertEquals("new-background", enriched.background) + } + + @Test + fun `placeholder id title still requests metadata when artwork exists`() { + val placeholder = entry().copy( + title = "show", + poster = "poster", + background = "background", + ) + val enriched = placeholder.copy(title = "Resolved Show") + + assertTrue(placeholder.needsRemoteMetadataEnrichment()) + assertTrue(!enriched.needsRemoteMetadataEnrichment()) + } + + @Test + fun `video id compatibility projection rejects ambiguous aliases and contextual lookup resolves them`() { + val older = entry( + parentMetaId = "show-a", + videoId = "shared-video", + progressKey = "opaque-a", + lastUpdatedEpochMs = 10L, + ) + val newer = entry( + parentMetaId = "show-b", + videoId = "shared-video", + progressKey = "opaque-b", + lastUpdatedEpochMs = 20L, + ) + val state = WatchProgressUiState(entries = listOf(older, newer)) + + assertTrue("shared-video" !in state.byVideoId) + assertEquals( + "opaque-a", + state.progressForVideo( + videoId = "shared-video", + parentMetaId = "show-a", + )?.resolvedProgressKey(), + ) + assertEquals(setOf("opaque-a", "opaque-b"), state.byProgressKey.keys) + } + + @Test + fun `contextual lookup rejects episodes that remain ambiguous within the same parent`() { + val episodeOne = entry( + parentMetaId = "show", + videoId = "shared-video", + progressKey = "episode-one", + lastUpdatedEpochMs = 10L, + ).copy(episodeNumber = 1) + val episodeTwo = episodeOne.copy( + progressKey = "episode-two", + episodeNumber = 2, + lastUpdatedEpochMs = 20L, + ) + val state = WatchProgressUiState(entries = listOf(episodeOne, episodeTwo)) + + assertEquals( + null, + state.progressForVideo( + videoId = "shared-video", + parentMetaId = "show", + ), + ) + assertEquals( + null, + state.progressForVideo( + videoId = "shared-video", + parentMetaId = "show", + seasonNumber = 1, + ), + ) + assertEquals( + "episode-two", + state.progressForVideo( + videoId = "shared-video", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 2, + )?.resolvedProgressKey(), + ) + } + + @Test + fun `optimistic update reuses exact opaque key before a fresher playback alias`() { + val exactPlayback = entry( + videoId = "provider-video-a", + progressKey = "remote-exact-key", + lastUpdatedEpochMs = 10L, + ) + val otherPlayback = exactPlayback.copy( + videoId = "provider-video-b", + progressKey = "remote-other-key", + lastUpdatedEpochMs = 20L, + ) + val candidate = exactPlayback.copy( + progressKey = null, + lastUpdatedEpochMs = 30L, + ) + + val resolved = listOf(otherPlayback, exactPlayback).resolveIdentityForUpsert(candidate) + + assertEquals("remote-exact-key", resolved.progressKey) + } + + @Test + fun `snapshot keeps distinct keys that share a playback video id`() { + val merged = WatchProgressRepository.mergeWatchProgressEntriesPreservingUnsynced( + serverEntries = listOf( + record(contentId = "show-a", progressKey = "opaque-a", lastWatched = 10L), + record(contentId = "show-b", progressKey = "opaque-b", lastWatched = 20L), + ), + localEntries = emptyList(), + dirtyProgressKeys = emptySet(), + ) + + assertEquals(setOf("opaque-a", "opaque-b"), merged.keys) + assertEquals(2, merged.values.count { it.videoId == "shared-video" }) + } + + @Test + fun `snapshot same-key winner is newest and independent of input order`() { + val older = record( + contentId = "show", + progressKey = "opaque", + videoId = "older-video", + lastWatched = 10L, + position = 100L, + ) + val newer = older.copy( + videoId = "newer-video", + lastWatched = 20L, + position = 200L, + ) + + val forward = mergeSnapshot(listOf(older, newer)).getValue("opaque") + val reversed = mergeSnapshot(listOf(newer, older)).getValue("opaque") + + assertEquals("newer-video", forward.videoId) + assertEquals(forward, reversed) + } + + @Test + fun `snapshot preserves unsynced local position when timestamps tie`() { + val merged = WatchProgressRepository.mergeWatchProgressEntriesPreservingUnsynced( + serverEntries = listOf( + record( + contentId = "show", + progressKey = "opaque", + lastWatched = 100L, + position = 100L, + ), + ), + localEntries = listOf( + entry( + progressKey = "opaque", + lastUpdatedEpochMs = 100L, + lastPositionMs = 200L, + ), + ), + dirtyProgressKeys = setOf("opaque"), + ) + + assertEquals(200L, merged.getValue("opaque").lastPositionMs) + } + + @Test + fun `delta upsert with equal timestamp and higher position wins`() { + val current = entry( + progressKey = "opaque", + lastUpdatedEpochMs = 100L, + lastPositionMs = 100L, + ) + val event = deltaEvent( + operation = "upsert", + progressKey = "opaque", + lastWatched = 100L, + position = 200L, + ) + + val decision = WatchProgressRepository.decideWatchProgressDeltaEvent( + current = current, + event = event, + isLocalDirty = false, + ) + + assertEquals(WatchProgressDeltaDecisionType.UPSERT, decision.type) + assertEquals(200L, decision.updatedEntry?.lastPositionMs) + } + + @Test + fun `delta delete preserves a local update made during the pull`() { + val decision = WatchProgressRepository.decideWatchProgressDeltaEvent( + current = entry(progressKey = "opaque", lastUpdatedEpochMs = 300L), + event = ProgressDeltaEvent( + eventId = 1L, + operation = "delete", + progressKey = "opaque", + ), + isLocalDirty = true, + ) + + assertEquals(WatchProgressDeltaDecisionType.PRESERVE_LOCAL, decision.type) + } + + @Test + fun `delta delete removes a clean remote-loaded entry even without a push watermark`() { + val decision = WatchProgressRepository.decideWatchProgressDeltaEvent( + current = entry(progressKey = "opaque", lastUpdatedEpochMs = 100L), + event = ProgressDeltaEvent( + eventId = 1L, + operation = "delete", + progressKey = "opaque", + ), + isLocalDirty = false, + ) + + assertEquals(WatchProgressDeltaDecisionType.DELETE, decision.type) + } + + @Test + fun `causally newer clean delta applies an authoritative rewind`() { + val decision = WatchProgressRepository.decideWatchProgressDeltaEvent( + current = entry( + progressKey = "opaque", + lastUpdatedEpochMs = 200L, + lastPositionMs = 800L, + ), + event = deltaEvent( + operation = "upsert", + progressKey = "opaque", + lastWatched = 100L, + position = 100L, + ), + isLocalDirty = false, + ) + + assertEquals(WatchProgressDeltaDecisionType.UPSERT, decision.type) + assertEquals(100L, decision.updatedEntry?.lastPositionMs) + assertTrue(decision.clearsDirtyProgress) + } + + @Test + fun `authoritative snapshot drops a clean cached row when watermark is zero`() { + val merged = WatchProgressRepository.mergeWatchProgressEntriesPreservingUnsynced( + serverEntries = emptyList(), + localEntries = listOf(entry(progressKey = "opaque")), + dirtyProgressKeys = emptySet(), + ) + + assertTrue(merged.isEmpty()) + } + + @Test + fun `snapshot migrates dirty legacy identity to sole opaque server key`() { + val merged = WatchProgressRepository.mergeWatchProgressEntriesPreservingUnsynced( + serverEntries = listOf( + record( + contentId = "show", + progressKey = "server/opaque", + lastWatched = 10L, + ), + ), + localEntries = listOf( + entry( + parentMetaId = "show", + progressKey = "show_s1e2", + lastUpdatedEpochMs = 20L, + ), + ), + dirtyProgressKeys = setOf("show_s1e2"), + ) + + assertEquals(setOf("server/opaque"), merged.keys) + assertEquals(20L, merged.getValue("server/opaque").lastUpdatedEpochMs) + } + + private fun mergeSnapshot(records: List): Map = + WatchProgressRepository.mergeWatchProgressEntriesPreservingUnsynced( + serverEntries = records, + localEntries = emptyList(), + dirtyProgressKeys = emptySet(), + ) + + private fun record( + contentId: String, + progressKey: String, + videoId: String = "shared-video", + lastWatched: Long, + position: Long = 100L, + ): ProgressSyncRecord = + ProgressSyncRecord( + contentId = contentId, + contentType = "series", + videoId = videoId, + season = 1, + episode = 2, + position = position, + duration = 1_000L, + lastWatched = lastWatched, + progressKey = progressKey, + ) + + private fun deltaEvent( + operation: String, + progressKey: String, + lastWatched: Long = 100L, + position: Long = 100L, + ): ProgressDeltaEvent = + ProgressDeltaEvent( + eventId = 1L, + operation = operation, + progressKey = progressKey, + contentId = "show", + contentType = "series", + videoId = "show:1:2", + season = 1, + episode = 2, + position = position, + duration = 1_000L, + lastWatched = lastWatched, + ) + + private fun entry( + parentMetaId: String = "show", + videoId: String = "show:1:2", + progressKey: String? = null, + lastUpdatedEpochMs: Long = 10L, + lastPositionMs: Long = 100L, + ): WatchProgressEntry = + WatchProgressEntry( + contentType = "series", + parentMetaId = parentMetaId, + parentMetaType = "series", + videoId = videoId, + title = "Show", + seasonNumber = 1, + episodeNumber = 2, + lastPositionMs = lastPositionMs, + durationMs = 1_000L, + lastUpdatedEpochMs = lastUpdatedEpochMs, + progressKey = progressKey, + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt index c9e1b1cb..fc1e76c9 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt @@ -21,6 +21,21 @@ class WatchProgressRulesTest { assertEquals(listOf("movie-2", "movie-1"), decoded.map { it.videoId }) } + @Test + fun `codec persists dirty progress keys and drops keys without a stored row`() { + val payload = WatchProgressCodec.encodePayload( + entries = listOf(entry(videoId = "show:1:2", progressKey = "opaque-key")), + lastSuccessfulPushEpochMs = 0L, + deltaCursorEventId = 0L, + deltaInitialized = false, + dirtyProgressKeys = setOf("opaque-key", "missing-key"), + ) + + val decoded = WatchProgressCodec.decodePayload(payload) + + assertEquals(setOf("opaque-key"), decoded.dirtyProgressKeys) + } + @Test fun `codec ignores corrupt payload`() { assertTrue(WatchProgressCodec.decodeEntries("{not json").isEmpty()) @@ -137,7 +152,7 @@ class WatchProgressRulesTest { } @Test - fun `continue watching keeps active resume even when a newer episode is completed`() { + fun `continue watching drops stale resume when a newer series episode is completed`() { val inProgress = entry( videoId = "show:1:4", parentMetaId = "show", @@ -156,7 +171,132 @@ class WatchProgressRulesTest { val result = listOf(inProgress, completed).continueWatchingEntries() - assertEquals(listOf("show:1:4"), result.map { it.videoId }) + assertTrue(result.isEmpty()) + } + + @Test + fun `continue watching keeps legitimate resume when it is newer than completed series progress`() { + val completed = entry( + videoId = "show:1:4", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 10L, + isCompleted = true, + ) + val inProgress = entry( + videoId = "show:1:5", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 5, + lastUpdatedEpochMs = 20L, + ) + + val result = listOf(completed, inProgress).continueWatchingEntries() + + assertEquals(listOf("show:1:5"), result.map { it.videoId }) + } + + @Test + fun `resume entry for series returns null when newer overall progress is completed`() { + val staleResume = entry( + videoId = "show:1:4", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 10L, + ) + val completed = entry( + videoId = "show:1:5", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 5, + lastUpdatedEpochMs = 20L, + isCompleted = true, + ) + + assertNull(listOf(staleResume, completed).resumeEntryForSeries("show")) + } + + @Test + fun `resume entry coalesces series type variants independent of input order`() { + val staleResume = entry( + videoId = "show:1:4", + parentMetaId = "show", + parentMetaType = "SeRiEs", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 10L, + ) + val completed = entry( + videoId = "show:1:5", + parentMetaId = "show", + parentMetaType = "TV", + seasonNumber = 1, + episodeNumber = 5, + lastUpdatedEpochMs = 20L, + isCompleted = true, + ) + + assertNull(listOf(staleResume, completed).resumeEntryForSeries("show")) + assertNull(listOf(completed, staleResume).resumeEntryForSeries("show")) + } + + @Test + fun `resume entry ignores a movie with the same content id`() { + val movie = entry( + videoId = "shared-movie", + parentMetaId = "shared", + parentMetaType = "movie", + lastUpdatedEpochMs = 30L, + ) + val seriesResume = entry( + videoId = "shared:1:1", + parentMetaId = "shared", + parentMetaType = "Show", + seasonNumber = 1, + episodeNumber = 1, + lastUpdatedEpochMs = 20L, + ) + + val forward = listOf(movie, seriesResume).resumeEntryForSeries("shared") + val reversed = listOf(seriesResume, movie).resumeEntryForSeries("shared") + + assertEquals(seriesResume.resolvedProgressKey(), forward?.resolvedProgressKey()) + assertEquals(seriesResume.resolvedProgressKey(), reversed?.resolvedProgressKey()) + } + + @Test + fun `continue watching does not cross map aliases that share a video id`() { + val activeShow = entry( + videoId = "shared-video", + parentMetaId = "active-show", + seasonNumber = 1, + episodeNumber = 1, + lastUpdatedEpochMs = 30L, + progressKey = "active-show_s1e1", + ) + val staleAlias = entry( + videoId = "shared-video", + parentMetaId = "completed-show", + seasonNumber = 1, + episodeNumber = 1, + lastUpdatedEpochMs = 10L, + progressKey = "completed-show_s1e1", + ) + val completedAlias = entry( + videoId = "completed-show:1:2", + parentMetaId = "completed-show", + seasonNumber = 1, + episodeNumber = 2, + lastUpdatedEpochMs = 20L, + isCompleted = true, + progressKey = "completed-show_s1e2", + ) + + val result = listOf(staleAlias, activeShow, completedAlias).continueWatchingEntries() + + assertEquals(listOf("active-show_s1e1"), result.map { it.resolvedProgressKey() }) } @Test @@ -316,17 +456,6 @@ class WatchProgressRulesTest { assertEquals("movie", buildPlaybackVideoId(parentMetaId = "movie", seasonNumber = null, episodeNumber = null, fallbackVideoId = null)) } - @Test - fun `snapshot preserves local only progress before first successful push`() { - assertTrue( - WatchProgressRepository.shouldPreserveLocalWatchProgressEntry( - localEntry = entry(videoId = "local-only", lastUpdatedEpochMs = 1_000L), - lastSuccessfulPushEpochMs = 0L, - pullStartedEpochMs = 2_000L, - ), - ) - } - @Test fun `up next continue watching uses actual episode id when available`() { val item = entry( @@ -352,7 +481,7 @@ class WatchProgressRulesTest { assertEquals(1779634800000L, t1) val t2 = parseReleaseDateToEpochMs("2026-05-24") - assertEquals(1779580800000L, t2) // 2026-05-24T00:00:00Z is 1779580800 seconds + assertEquals(CurrentDateProvider.localStartOfDayEpochMs("2026-05-24"), t2) assertNull(parseReleaseDateToEpochMs(null)) assertNull(parseReleaseDateToEpochMs(" ")) @@ -364,17 +493,19 @@ class WatchProgressRulesTest { parentMetaId: String = videoId.substringBefore(':'), seasonNumber: Int? = null, episodeNumber: Int? = null, + parentMetaType: String = if (seasonNumber != null && episodeNumber != null) "series" else "movie", lastUpdatedEpochMs: Long = 1L, lastPositionMs: Long = 120_000L, durationMs: Long = 1_000_000L, isCompleted: Boolean = false, progressPercent: Float? = null, source: String = WatchProgressSourceLocal, + progressKey: String? = null, ): WatchProgressEntry = WatchProgressEntry( - contentType = if (seasonNumber != null && episodeNumber != null) "series" else "movie", + contentType = parentMetaType, parentMetaId = parentMetaId, - parentMetaType = if (seasonNumber != null && episodeNumber != null) "series" else "movie", + parentMetaType = parentMetaType, videoId = videoId, title = "Title", seasonNumber = seasonNumber, @@ -385,5 +516,6 @@ class WatchProgressRulesTest { isCompleted = isCompleted, progressPercent = progressPercent, source = source, + progressKey = progressKey, ) } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.ios.kt index fa753e59..f437ac57 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.ios.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.watchprogress import platform.Foundation.NSDate import platform.Foundation.NSDateFormatter +import platform.Foundation.timeIntervalSince1970 actual object CurrentDateProvider { actual fun todayIsoDate(): String { @@ -9,5 +10,10 @@ actual object CurrentDateProvider { formatter.dateFormat = "yyyy-MM-dd" return formatter.stringFromDate(NSDate()) } -} + actual fun localStartOfDayEpochMs(isoDate: String): Long? { + val formatter = NSDateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter.dateFromString(isoDate)?.timeIntervalSince1970?.times(1_000.0)?.toLong() + } +}