mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 14:32:28 +00:00
Merge branch 'cwenrichmentfix' into cmp-rewrite
This commit is contained in:
commit
4b83967a27
30 changed files with 3066 additions and 727 deletions
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@ package com.nuvio.app.features.watchprogress
|
|||
|
||||
expect object CurrentDateProvider {
|
||||
fun todayIsoDate(): String
|
||||
fun localStartOfDayEpochMs(isoDate: String): Long?
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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?,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue