Merge branch 'cwenrichmentfix' into cmp-rewrite

This commit is contained in:
tapframe 2026-07-14 01:30:36 +05:30
commit 4b83967a27
30 changed files with 3066 additions and 727 deletions

View file

@ -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()
}

View file

@ -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 (

View file

@ -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,

View file

@ -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<String, Pair<Long, ContinueWatchingItem>>()
@ -545,12 +582,13 @@ fun HomeScreen(
val results = Channel<HomeNextUpCandidateResolution>(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<MetaPreview>,
@ -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<String, Pair<Long, ContinueWatchingItem>>,
cachedItems: Map<String, Pair<Long, ContinueWatchingItem>>,
conclusivelyProcessedContentIds: Set<String>,
): Map<String, Pair<Long, ContinueWatchingItem>> {
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<String>,
): 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<WatchProgressEntry>,
watchedItems: List<WatchedItem>,
cachedFallbackItem: ContinueWatchingItem?,
todayIsoDate: String,
preferFurthestEpisode: Boolean,
showUnairedNextUp: Boolean,
dismissedNextUpKeys: Set<String>,
isTraktProgressActive: Boolean,
): Pair<String, Pair<Long, ContinueWatchingItem>>? {
): 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<WatchProgressEntry>,
cachedInProgressByVideoId: Map<String, ContinueWatchingItem> = emptyMap(),
cachedInProgressByProgressKey: Map<String, ContinueWatchingItem> = emptyMap(),
nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>,
nextUpSuppressedSeriesIds: Set<String>? = 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<String, Pair<Long, ContinueWatchingItem>>?,
val attempt: HomeNextUpResolutionAttempt,
)
private data class HomeNextUpResolutionAttempt(
val resolved: Pair<String, Pair<Long, ContinueWatchingItem>>?,
val isConclusive: Boolean,
) {
companion object {
fun success(
resolved: Pair<String, Pair<Long, ContinueWatchingItem>>,
): 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<WatchProgressEntry>,
cachedEntries: List<CachedInProgressItem>,
): List<CachedInProgressItem> {
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<WatchedItem>,
contentId: String,
): List<WatchedItem> {
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
}
}
}
}

View file

@ -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 }

View file

@ -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,

View file

@ -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 }

View file

@ -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<Unit>? = null
private var metadataHydrationJob: Job? = null
private var remoteEntriesSnapshot: List<WatchProgressEntry> = 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<String, String>): Boolean {
val activities = runCatching {
val endpoint = "$BASE_URL/sync/last_activities"
val payload = httpGetTextWithHeaders(
url = endpoint,
headers = headers,
)
json.decodeFromString<TraktLastActivitiesResponse>(
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<WatchProgressEntry>,
) {
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<String, String>): List<WatchProgressEntry> = 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<List<TraktHistoryMovieItem>>(payload)
val mapped = json.decodeFromString<List<TraktHistoryMovieItem>>(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,

View file

@ -22,6 +22,7 @@ data class WatchedUiState(
val items: List<WatchedItem> = emptyList(),
val watchedKeys: Set<String> = emptySet(),
val isLoaded: Boolean = false,
val hasLoadedRemoteItems: Boolean = false,
)
fun MetaPreview.toWatchedItem(markedAtEpochMs: Long): WatchedItem =

View file

@ -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<String> = emptySet(),
)
internal enum class WatchedTraktHistorySync {
@ -121,6 +124,9 @@ object WatchedRepository {
private var traktFullyWatchedSeriesKeys: Set<String> = 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<String> = 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<String, WatchedItem>,
dirtyKeys: MutableSet<String>,
events: Collection<WatchedDeltaEvent>,
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<String, WatchedItem>,
private fun findWatchedItemStableDeleteKey(
targetItems: Map<String, WatchedItem>,
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<WatchedItem>,
) {
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<String, WatchedItem>,
val dirtyKeys: Set<String>,
)
internal fun mergeWatchedSnapshot(
serverItems: Collection<WatchedItem>,
localItems: Collection<WatchedItem>,
lastSuccessfulPushEpochMs: Long,
pullStartedEpochMs: Long,
preserveWhenNoSuccessfulPush: Boolean = true,
): Map<String, WatchedItem> {
val merged = serverItems
dirtyKeys: Set<String>,
): 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<String, WatchedItem>,
dirtyKeys: Set<String>,
pushedItems: Collection<WatchedItem>,
): Set<String> {
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(

View file

@ -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)
}

View file

@ -6,25 +6,56 @@ import com.nuvio.app.core.i18n.localizedUpNextLabel
const val DefaultContinueWatchingLimit = 20
private val watchingProgressRecencyComparator =
compareBy<WatchingProgressRecord> { 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>,
): 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<WatchingProgressRecord>,
limit: Int = DefaultContinueWatchingLimit,
): List<WatchingProgressRecord> {
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)
}

View file

@ -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(

View file

@ -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,

View file

@ -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<WatchProgressSyncEntry>()
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<WatchProgressEntry>,
) {
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,

View file

@ -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(

View file

@ -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<CachedNextUpItem> = emptyList(),

View file

@ -2,5 +2,5 @@ package com.nuvio.app.features.watchprogress
expect object CurrentDateProvider {
fun todayIsoDate(): String
fun localStartOfDayEpochMs(isoDate: String): Long?
}

View file

@ -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<WatchProgressEntry> =
compareBy<WatchProgressEntry> { 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<WatchProgressEntry>.newestByProgressKey(): Map<String, WatchProgressEntry> {
val result = linkedMapOf<String, WatchProgressEntry>()
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<WatchProgressEntry>.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<WatchProgressEntry>.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<WatchProgressEntry>,
val migratedKeys: Map<String, String>,
)
/**
* 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<ProgressSyncRecord>,
localEntries: Collection<WatchProgressEntry>,
): 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<String, String>()
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,
)
}

View file

@ -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<WatchProgressEntry> = emptyList(),
val hasLoadedRemoteProgress: Boolean = false,
) {
val byProgressKey: Map<String, WatchProgressEntry>
get() = entries.newestByProgressKey()
/** Secondary compatibility lookup; multiple server rows may share a video id. */
val byVideoId: Map<String, WatchProgressEntry>
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<String, WatchProgressEntry> =
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<WatchProgressEntry>
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?,

View file

@ -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<String> = emptySet(),
)
internal object WatchProgressCodec {
@ -35,8 +37,16 @@ internal object WatchProgressCodec {
fun decodePayload(payload: String): StoredWatchProgressPayload =
runCatching {
json.decodeFromString<StoredWatchProgressPayload>(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<String> = 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<WatchProgressEntry>.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<WatchProgressEntry>.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<WatchProgressEntry>.continueWatchingEntries(
limit: Int = ContinueWatchingLimit,
): List<WatchProgressEntry> {
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(),
)
}

View file

@ -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"),

View file

@ -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,
)
}

View file

@ -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

View file

@ -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

View file

@ -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(

View file

@ -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<ProgressSyncRecord>): Map<String, WatchProgressEntry> =
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,
)
}

View file

@ -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,
)
}

View file

@ -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()
}
}