fix: cw seeding and series continuity

This commit is contained in:
tapframe 2026-05-22 12:29:35 +05:30
parent efddba9df2
commit 29f78a98cb
5 changed files with 320 additions and 194 deletions

View file

@ -142,14 +142,33 @@ internal fun MetaDetails.seriesPrimaryAction(
watchedItems: List<WatchedItem>,
todayIsoDate: String,
preferFurthestEpisode: Boolean = true,
showUnairedNextUp: Boolean = false,
): SeriesPrimaryAction? =
seriesPrimaryAction(
content = WatchingContentRef(type = type, id = id),
entries = entries,
watchedItems = watchedItems,
todayIsoDate = todayIsoDate,
preferFurthestEpisode = preferFurthestEpisode,
showUnairedNextUp = showUnairedNextUp,
)
internal fun MetaDetails.seriesPrimaryAction(
content: WatchingContentRef,
entries: List<WatchProgressEntry>,
watchedItems: List<WatchedItem>,
todayIsoDate: String,
preferFurthestEpisode: Boolean = true,
showUnairedNextUp: Boolean = false,
): SeriesPrimaryAction? =
decideSeriesPrimaryAction(
content = WatchingContentRef(type = type, id = id),
content = content,
episodes = videos.map(MetaVideo::toDomainReleasedEpisode),
progressRecords = entries.map(WatchProgressEntry::toDomainProgressRecord),
watchedRecords = watchedItems.map(WatchedItem::toDomainWatchedRecord),
todayIsoDate = todayIsoDate,
preferFurthestEpisode = preferFurthestEpisode,
showUnairedNextUp = showUnairedNextUp,
)?.toLegacySeriesPrimaryAction()
internal fun MetaVideo.playLabel(): String =

View file

@ -21,8 +21,11 @@ import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.nextReleasedEpisodeAfter
import com.nuvio.app.features.details.MetaVideo
import com.nuvio.app.features.details.SeriesPrimaryAction
import com.nuvio.app.features.details.seriesPrimaryAction
import com.nuvio.app.features.home.components.HomeCatalogRowSection
import com.nuvio.app.features.home.components.HomeContinueWatchingSection
import com.nuvio.app.features.home.components.HomeEmptyStateCard
@ -44,6 +47,7 @@ import com.nuvio.app.features.watchprogress.CurrentDateProvider
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
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.shouldTreatAsInProgressForContinueWatching
@ -51,14 +55,12 @@ import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueW
import com.nuvio.app.features.watchprogress.WatchProgressClock
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.features.watchprogress.WatchProgressSourceLocal
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktHistory
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktShowProgress
import com.nuvio.app.features.watchprogress.buildContinueWatchingEpisodeSubtitle
import com.nuvio.app.features.watchprogress.continueWatchingEntries
import com.nuvio.app.features.watchprogress.toContinueWatchingItem
import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem
import com.nuvio.app.features.watching.application.WatchingState
import com.nuvio.app.features.watching.domain.WatchingContentRef
import com.nuvio.app.features.watching.domain.isReleasedBy
import com.nuvio.app.features.collection.CollectionRepository
@ -167,47 +169,41 @@ fun HomeScreen(
)
}
val effectiveWatchedItems = remember(watchedUiState.items, isTraktProgressActive) {
if (isTraktProgressActive) emptyList() else watchedUiState.items
}
val allNextUpSeedEntries = remember(
val allNextUpSeedCandidates = remember(
watchProgressUiState.entries,
effectiveWatchedItems,
watchedUiState.items,
isTraktProgressActive,
continueWatchingPreferences.upNextFromFurthestEpisode,
) {
buildTvParityNextUpSeedEntries(
buildHomeNextUpSeedCandidates(
progressEntries = watchProgressUiState.entries,
watchedItems = effectiveWatchedItems,
watchedItems = watchedUiState.items,
isTraktProgressActive = isTraktProgressActive,
preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode,
nowEpochMs = WatchProgressClock.nowEpochMs(),
)
}
val recentNextUpSeedEntries = remember(
allNextUpSeedEntries,
val recentNextUpSeedCandidates = remember(
allNextUpSeedCandidates,
isTraktProgressActive,
traktSettingsUiState.continueWatchingDaysCap,
) {
filterEntriesForTraktContinueWatchingWindow(
entries = allNextUpSeedEntries,
filterHomeNextUpCandidatesForTraktContinueWatchingWindow(
candidates = allNextUpSeedCandidates,
isTraktProgressActive = isTraktProgressActive,
daysCap = traktSettingsUiState.continueWatchingDaysCap,
nowEpochMs = WatchProgressClock.nowEpochMs(),
)
}
val activeNextUpSeedContentIds = remember(allNextUpSeedEntries) {
allNextUpSeedEntries.mapTo(mutableSetOf()) { entry -> entry.parentMetaId }
val activeNextUpSeedContentIds = remember(allNextUpSeedCandidates) {
allNextUpSeedCandidates.mapTo(mutableSetOf()) { candidate -> candidate.content.id }
}
val currentNextUpSeedByContentId = remember(allNextUpSeedEntries) {
allNextUpSeedEntries.mapNotNull { entry ->
val season = entry.seasonNumber ?: return@mapNotNull null
val episode = entry.episodeNumber ?: return@mapNotNull null
entry.parentMetaId to (season to episode)
val currentNextUpSeedByContentId = remember(allNextUpSeedCandidates) {
allNextUpSeedCandidates.associate { candidate ->
candidate.content.id to (candidate.seasonNumber to candidate.episodeNumber)
}.toMap()
}
@ -215,10 +211,10 @@ fun HomeScreen(
effectiveWatchProgressEntries.continueWatchingEntries()
}
val latestCompletedAtBySeries = remember(allNextUpSeedEntries) {
allNextUpSeedEntries
.groupBy { entry -> entry.parentMetaId }
.mapValues { (_, entries) -> entries.maxOfOrNull { entry -> entry.lastUpdatedEpochMs } ?: Long.MIN_VALUE }
val latestCompletedAtBySeries = remember(allNextUpSeedCandidates) {
allNextUpSeedCandidates
.groupBy { candidate -> candidate.content.id }
.mapValues { (_, candidates) -> candidates.maxOfOrNull { candidate -> candidate.markedAtEpochMs } ?: Long.MIN_VALUE }
}
val nextUpSuppressedSeriesIds = remember(visibleContinueWatchingEntries, latestCompletedAtBySeries) {
@ -236,17 +232,9 @@ fun HomeScreen(
.toSet()
}
val completedSeriesCandidates = remember(recentNextUpSeedEntries, nextUpSuppressedSeriesIds) {
recentNextUpSeedEntries.mapNotNull { seed ->
val season = seed.seasonNumber ?: return@mapNotNull null
val episode = seed.episodeNumber ?: return@mapNotNull null
if (season == 0 || seed.parentMetaId in nextUpSuppressedSeriesIds) return@mapNotNull null
CompletedSeriesCandidate(
content = WatchingContentRef(type = seed.parentMetaType, id = seed.parentMetaId),
seasonNumber = season,
episodeNumber = episode,
markedAtEpochMs = seed.lastUpdatedEpochMs,
)
val completedSeriesCandidates = remember(recentNextUpSeedCandidates, nextUpSuppressedSeriesIds) {
recentNextUpSeedCandidates.filter { candidate ->
candidate.content.id !in nextUpSuppressedSeriesIds
}
}
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
@ -256,6 +244,17 @@ fun HomeScreen(
var processedNextUpContentIds by remember(activeProfileId) { mutableStateOf<Set<String>>(emptySet()) }
val cachedSnapshots = remember(activeProfileId) { ContinueWatchingEnrichmentCache.getSnapshots() }
val shouldValidateMissingNextUpSeeds = remember(
isTraktProgressActive,
watchProgressUiState.hasLoadedRemoteProgress,
watchedUiState.isLoaded,
) {
if (isTraktProgressActive) {
watchProgressUiState.hasLoadedRemoteProgress
} else {
watchedUiState.isLoaded
}
}
val cachedNextUpItems = remember(
cachedSnapshots.first,
continueWatchingPreferences.dismissedNextUpKeys,
@ -263,6 +262,7 @@ fun HomeScreen(
currentNextUpSeedByContentId,
isTraktProgressActive,
watchProgressUiState.hasLoadedRemoteProgress,
shouldValidateMissingNextUpSeeds,
processedNextUpContentIds,
nextUpItemsBySeries,
continueWatchingPreferences.showUnairedNextUp,
@ -270,25 +270,13 @@ fun HomeScreen(
) {
cachedSnapshots.first.mapNotNull { cached ->
if (
!isTraktProgressActive &&
watchedUiState.isLoaded &&
cached.contentId !in activeNextUpSeedContentIds
) {
return@mapNotNull null
}
if (
isTraktProgressActive &&
watchProgressUiState.hasLoadedRemoteProgress &&
shouldValidateMissingNextUpSeeds &&
cached.contentId !in activeNextUpSeedContentIds
) {
return@mapNotNull null
}
val currentSeed = currentNextUpSeedByContentId[cached.contentId]
if (
currentSeed != null &&
cached.seedSeason != null &&
cached.seedEpisode != null
) {
if (currentSeed != null) {
val (currentSeason, currentEpisode) = currentSeed
val seedChanged = currentSeason != cached.seedSeason || currentEpisode != cached.seedEpisode
if (seedChanged) return@mapNotNull null
@ -321,8 +309,16 @@ fun HomeScreen(
nextUpItemsBySeries,
cachedNextUpItems,
continueWatchingPreferences.dismissedNextUpKeys,
activeNextUpSeedContentIds,
currentNextUpSeedByContentId,
shouldValidateMissingNextUpSeeds,
) {
val liveNextUpItems = nextUpItemsBySeries.filterValues { (_, item) ->
val liveNextUpItems = filterNextUpItemsByCurrentSeeds(
nextUpItemsBySeries = nextUpItemsBySeries,
activeSeedContentIds = activeNextUpSeedContentIds,
currentSeedByContentId = currentNextUpSeedByContentId,
shouldDropItemsWithoutActiveSeed = shouldValidateMissingNextUpSeeds,
).filterValues { (_, item) ->
nextUpDismissKey(
item.parentMetaId,
item.nextUpSeedSeasonNumber,
@ -396,6 +392,9 @@ fun HomeScreen(
visibleContinueWatchingEntries,
metaProviderKey,
continueWatchingPreferences.showUnairedNextUp,
continueWatchingPreferences.upNextFromFurthestEpisode,
watchProgressUiState.entries,
watchedUiState.items,
) {
if (completedSeriesCandidates.isEmpty()) {
nextUpItemsBySeries = emptyMap()
@ -446,12 +445,18 @@ fun HomeScreen(
if (meta == null) {
return@withPermit null
}
val nextEpisode = meta.nextReleasedEpisodeAfter(
seasonNumber = completedEntry.seasonNumber,
episodeNumber = completedEntry.episodeNumber,
val action = meta.seriesPrimaryAction(
content = completedEntry.content,
entries = watchProgressUiState.entries,
watchedItems = watchedUiState.items,
todayIsoDate = todayIsoDate,
preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode,
showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp,
)
if (action?.resumePositionMs != null) {
return@withPermit null
}
val nextEpisode = action?.let { meta.videoForSeriesAction(it) }
if (nextEpisode == null) {
return@withPermit null
}
@ -717,40 +722,99 @@ internal fun filterEntriesForTraktContinueWatchingWindow(
return entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs }
}
private fun buildTvParityNextUpSeedEntries(
internal fun filterHomeNextUpCandidatesForTraktContinueWatchingWindow(
candidates: List<CompletedSeriesCandidate>,
isTraktProgressActive: Boolean,
daysCap: Int,
nowEpochMs: Long,
): List<CompletedSeriesCandidate> {
if (!isTraktProgressActive) return candidates
val normalizedDaysCap = normalizeTraktContinueWatchingDaysCap(daysCap)
if (normalizedDaysCap == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) return candidates
val cutoffMs = nowEpochMs - (normalizedDaysCap.toLong() * MILLIS_PER_DAY)
return candidates.filter { candidate -> candidate.markedAtEpochMs >= cutoffMs }
}
internal fun buildHomeNextUpSeedCandidates(
progressEntries: List<WatchProgressEntry>,
watchedItems: List<WatchedItem>,
isTraktProgressActive: Boolean,
preferFurthestEpisode: Boolean,
nowEpochMs: Long,
): List<WatchProgressEntry> {
val rawSeeds = if (isTraktProgressActive) {
progressEntries.asSequence()
.filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() }
.filter { entry -> entry.seasonNumber != null && entry.episodeNumber != null && entry.seasonNumber != 0 }
.filter { entry -> shouldUseAsTraktNextUpSeed(entry, nowEpochMs) }
.toList()
} else {
watchedItems.asSequence()
.filter { item -> item.type.isSeriesTypeForContinueWatching() }
.filter { item -> item.season != null && item.episode != null && item.season != 0 }
.filter { item -> !isMalformedNextUpSeedContentId(item.id) }
.map { item -> item.toNextUpSeedEntry() }
.toList()
): List<CompletedSeriesCandidate> {
val progressSeeds = progressEntries
.asSequence()
.filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() }
.filter { entry -> entry.seasonNumber != null && entry.episodeNumber != null && entry.seasonNumber != 0 }
.filter { entry -> !isMalformedNextUpSeedContentId(entry.parentMetaId) }
.filter { entry ->
if (isTraktProgressActive) {
shouldUseAsTraktNextUpSeed(entry = entry, nowEpochMs = nowEpochMs)
} else {
entry.shouldUseAsCompletedSeedForContinueWatching()
}
}
.toList()
val watchedSeeds = watchedItems.filter { item ->
item.type.isSeriesTypeForContinueWatching() &&
item.season != null &&
item.episode != null &&
item.season != 0 &&
!isMalformedNextUpSeedContentId(item.id)
}
return if (isTraktProgressActive) {
mergeTvTraktNextUpSeeds(rawSeeds)
} else {
rawSeeds
.groupBy { entry -> nextUpSeedKey(entry) }
.mapNotNull { (_, entries) ->
choosePreferredNextUpSeed(
entries = entries,
preferFurthestEpisode = preferFurthestEpisode,
)
}
.sortedByDescending { entry -> entry.lastUpdatedEpochMs }
return WatchingState.latestCompletedBySeries(
progressEntries = progressSeeds,
watchedItems = watchedSeeds,
preferFurthestEpisode = preferFurthestEpisode,
).mapNotNull { (content, completed) ->
if (!content.type.isSeriesTypeForContinueWatching()) return@mapNotNull null
if (completed.seasonNumber == 0) return@mapNotNull null
if (isMalformedNextUpSeedContentId(content.id)) return@mapNotNull null
CompletedSeriesCandidate(
content = content,
seasonNumber = completed.seasonNumber,
episodeNumber = completed.episodeNumber,
markedAtEpochMs = completed.markedAtEpochMs,
)
}.sortedWith(
compareByDescending<CompletedSeriesCandidate> { candidate -> candidate.markedAtEpochMs }
.thenByDescending { candidate -> candidate.seasonNumber }
.thenByDescending { candidate -> candidate.episodeNumber },
)
}
internal fun filterNextUpItemsByCurrentSeeds(
nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>,
activeSeedContentIds: Set<String>,
currentSeedByContentId: Map<String, Pair<Int, Int>>,
shouldDropItemsWithoutActiveSeed: Boolean,
): Map<String, Pair<Long, ContinueWatchingItem>> =
nextUpItemsBySeries.filter { (contentId, pair) ->
if (shouldDropItemsWithoutActiveSeed && contentId !in activeSeedContentIds) {
return@filter false
}
val item = pair.second
val currentSeed = currentSeedByContentId[contentId] ?: return@filter true
item.nextUpSeedSeasonNumber == currentSeed.first &&
item.nextUpSeedEpisodeNumber == currentSeed.second
}
private fun MetaDetails.videoForSeriesAction(action: SeriesPrimaryAction): MetaVideo? {
if (action.seasonNumber != null && action.episodeNumber != null) {
videos.firstOrNull { video ->
video.season == action.seasonNumber &&
video.episode == action.episodeNumber
}?.let { return it }
}
return videos.firstOrNull { video ->
com.nuvio.app.features.watchprogress.buildPlaybackVideoId(
parentMetaId = id,
seasonNumber = video.season,
episodeNumber = video.episode,
fallbackVideoId = video.id,
) == action.videoId || video.id == action.videoId
}
}
@ -765,103 +829,6 @@ private fun shouldUseAsTraktNextUpSeed(
return ageMs in 0..OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS
}
private fun WatchedItem.toNextUpSeedEntry(): WatchProgressEntry =
WatchProgressEntry(
contentType = type,
parentMetaId = id,
parentMetaType = type,
videoId = id,
title = name,
poster = poster,
seasonNumber = season,
episodeNumber = episode,
lastPositionMs = 1L,
durationMs = 1L,
lastUpdatedEpochMs = markedAtEpochMs,
isCompleted = true,
progressPercent = 100f,
source = WatchProgressSourceLocal,
)
private fun nextUpSeedKey(entry: WatchProgressEntry): String =
entry.parentMetaId.trim()
private fun mergeTvTraktNextUpSeeds(entries: List<WatchProgressEntry>): List<WatchProgressEntry> {
val merged = linkedMapOf<String, WatchProgressEntry>()
entries
.filter { entry -> entry.source == WatchProgressSourceTraktShowProgress }
.forEach { seed ->
merged[nextUpSeedKey(seed)] = seed
}
entries
.filter { entry -> entry.source == WatchProgressSourceTraktHistory || entry.source == WatchProgressSourceTraktPlayback }
.forEach { seed ->
val key = nextUpSeedKey(seed)
val existing = merged[key]
if (existing == null || shouldReplaceNextUpSeed(existing, seed)) {
merged[key] = seed
}
}
return merged.values.sortedByDescending { entry -> entry.lastUpdatedEpochMs }
}
private fun shouldReplaceNextUpSeed(
existing: WatchProgressEntry,
candidate: WatchProgressEntry,
): Boolean {
val candidateSeason = candidate.seasonNumber ?: -1
val candidateEpisode = candidate.episodeNumber ?: -1
val existingSeason = existing.seasonNumber ?: -1
val existingEpisode = existing.episodeNumber ?: -1
return candidateSeason > existingSeason ||
(
candidateSeason == existingSeason &&
(
candidateEpisode > existingEpisode ||
(
candidateEpisode == existingEpisode &&
candidate.lastUpdatedEpochMs >= existing.lastUpdatedEpochMs
)
)
)
}
private fun choosePreferredNextUpSeed(
entries: List<WatchProgressEntry>,
preferFurthestEpisode: Boolean,
): WatchProgressEntry? {
if (entries.isEmpty()) return null
val bestRank = entries.minOf(::nextUpSeedSourceRank)
return entries
.asSequence()
.filter { entry -> nextUpSeedSourceRank(entry) == bestRank }
.maxWithOrNull(
if (preferFurthestEpisode) {
compareBy<WatchProgressEntry>(
{ it.seasonNumber ?: -1 },
{ it.episodeNumber ?: -1 },
{ it.lastUpdatedEpochMs },
)
} else {
compareBy<WatchProgressEntry>(
{ it.lastUpdatedEpochMs },
{ it.seasonNumber ?: -1 },
{ it.episodeNumber ?: -1 },
)
},
)
}
private fun nextUpSeedSourceRank(entry: WatchProgressEntry): Int =
when (entry.source) {
WatchProgressSourceTraktPlayback,
WatchProgressSourceTraktShowProgress,
-> 0
WatchProgressSourceTraktHistory -> 1
WatchProgressSourceLocal -> 2
else -> 4
}
private fun shouldTreatAsActiveInProgressForNextUpSuppression(
progress: WatchProgressEntry,
latestCompletedAt: Long?,
@ -871,15 +838,6 @@ private fun shouldTreatAsActiveInProgressForNextUpSuppression(
return progress.lastUpdatedEpochMs >= latestCompletedAt
}
private fun isMalformedNextUpSeedContentId(contentId: String?): Boolean {
val trimmed = contentId?.trim().orEmpty()
if (trimmed.isEmpty()) return true
return when (trimmed.lowercase()) {
"tmdb", "imdb", "trakt", "tmdb:", "imdb:", "trakt:" -> true
else -> false
}
}
private fun heroMobileBelowSectionHeightHint(
maxWidthDp: Float,
continueWatchingVisible: Boolean,
@ -994,7 +952,7 @@ private fun applyStreamingStyleSort(
return sortedReleased + sortedUnreleased
}
private data class CompletedSeriesCandidate(
internal data class CompletedSeriesCandidate(
val content: WatchingContentRef,
val seasonNumber: Int,
val episodeNumber: Int,

View file

@ -41,6 +41,7 @@ fun nextReleasedEpisodeAfter(
seasonNumber: Int?,
episodeNumber: Int?,
todayIsoDate: String,
showUnairedNextUp: Boolean = false,
): WatchingReleasedEpisode? {
val sortedEpisodes = episodes.sortedWith(
compareBy<WatchingReleasedEpisode>({ normalizeSeasonNumber(it.seasonNumber) }, { it.episodeNumber ?: 0 }),
@ -76,7 +77,7 @@ fun nextReleasedEpisodeAfter(
candidateSeasonNumber = episode.seasonNumber,
todayIsoDate = todayIsoDate,
releasedDate = episode.releasedDate,
showUnairedNextUp = false,
showUnairedNextUp = showUnairedNextUp,
)
}
return candidates.firstOrNull { normalizeSeasonNumber(it.seasonNumber) > 0 }
@ -89,6 +90,7 @@ fun decideSeriesPrimaryAction(
watchedRecords: List<WatchingWatchedRecord>,
todayIsoDate: String,
preferFurthestEpisode: Boolean = true,
showUnairedNextUp: Boolean = false,
): WatchingSeriesPrimaryAction? {
val resumeRecord = resumeProgressForSeries(
content = content,
@ -112,6 +114,7 @@ fun decideSeriesPrimaryAction(
seasonNumber = latestCompletedEpisode.seasonNumber,
episodeNumber = latestCompletedEpisode.episodeNumber,
todayIsoDate = todayIsoDate,
showUnairedNextUp = showUnairedNextUp,
)
} else {
val sorted = episodes

View file

@ -2,6 +2,7 @@ package com.nuvio.app.features.details
import com.nuvio.app.features.watched.WatchedItem
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watching.domain.WatchingContentRef
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
@ -89,6 +90,46 @@ class SeriesPlaybackResolverTest {
assertEquals("show:1:3", action.videoId)
}
@Test
fun seriesPrimaryAction_uses_explicit_content_when_meta_id_is_alias() {
val meta = MetaDetails(
id = "tt1234567",
type = "series",
name = "Show",
videos = listOf(
MetaVideo(id = "s4e14", title = "Episode 14", season = 4, episode = 14, released = "2026-03-01"),
MetaVideo(id = "s4e15", title = "Episode 15", season = 4, episode = 15, released = "2026-03-08"),
),
)
val action = meta.seriesPrimaryAction(
content = WatchingContentRef(type = "series", id = "tmdb:98765"),
entries = listOf(
WatchProgressEntry(
contentType = "series",
parentMetaId = "tmdb:98765",
parentMetaType = "series",
videoId = "tmdb:98765:4:14",
title = "Show",
seasonNumber = 4,
episodeNumber = 14,
lastPositionMs = 10_000L,
durationMs = 10_000L,
lastUpdatedEpochMs = 100L,
isCompleted = true,
),
),
watchedItems = emptyList(),
todayIsoDate = "2026-03-30",
)
assertNotNull(action)
assertEquals("Up Next • S4E15", action.label)
assertEquals("tmdb:98765:4:15", action.videoId)
assertEquals(4, action.seasonNumber)
assertEquals(15, action.episodeNumber)
}
@Test
fun nextReleasedEpisodeAfter_global_index_fallback_ignores_specials() {
val meta = MetaDetails(

View file

@ -2,9 +2,11 @@ package com.nuvio.app.features.home
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watched.WatchedItem
import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class HomeScreenTest {
@ -146,6 +148,85 @@ class HomeScreenTest {
assertEquals(listOf("old", "recent"), result.map(WatchProgressEntry::videoId))
}
@Test
fun `home next up seed uses completed progress when watched item lags on Nuvio Sync`() {
val completedProgress = progressEntry(
videoId = "show:4:14",
title = "Show",
seasonNumber = 4,
episodeNumber = 14,
lastUpdatedEpochMs = 2_000L,
isCompleted = true,
)
val olderWatchedItem = watchedItem(
id = "show",
season = 4,
episode = 10,
markedAtEpochMs = 1_000L,
)
val result = buildHomeNextUpSeedCandidates(
progressEntries = listOf(completedProgress),
watchedItems = listOf(olderWatchedItem),
isTraktProgressActive = false,
preferFurthestEpisode = true,
nowEpochMs = 3_000L,
)
assertEquals(1, result.size)
assertEquals("show", result.single().content.id)
assertEquals(4, result.single().seasonNumber)
assertEquals(14, result.single().episodeNumber)
}
@Test
fun `home next up seed uses furthest watched item when progress is older`() {
val olderCompletedProgress = progressEntry(
videoId = "show:4:10",
title = "Show",
seasonNumber = 4,
episodeNumber = 10,
lastUpdatedEpochMs = 2_000L,
isCompleted = true,
)
val newerWatchedItem = watchedItem(
id = "show",
season = 4,
episode = 14,
markedAtEpochMs = 1_000L,
)
val result = buildHomeNextUpSeedCandidates(
progressEntries = listOf(olderCompletedProgress),
watchedItems = listOf(newerWatchedItem),
isTraktProgressActive = false,
preferFurthestEpisode = true,
nowEpochMs = 3_000L,
)
assertEquals(4, result.single().seasonNumber)
assertEquals(14, result.single().episodeNumber)
}
@Test
fun `stale live next up item is dropped when current seed advances`() {
val staleNextUp = continueWatchingItem(
videoId = "show:4:11",
subtitle = "Up Next • S4E11",
seedSeasonNumber = 4,
seedEpisodeNumber = 10,
)
val result = filterNextUpItemsByCurrentSeeds(
nextUpItemsBySeries = mapOf("show" to (1_000L to staleNextUp)),
activeSeedContentIds = setOf("show"),
currentSeedByContentId = mapOf("show" to (4 to 14)),
shouldDropItemsWithoutActiveSeed = true,
)
assertTrue(result.isEmpty())
}
private fun progressEntry(
videoId: String,
title: String,
@ -153,6 +234,7 @@ class HomeScreenTest {
seasonNumber: Int? = 1,
episodeNumber: Int? = 4,
episodeTitle: String? = "Episode",
isCompleted: Boolean = false,
): WatchProgressEntry =
WatchProgressEntry(
contentType = if (seasonNumber != null && episodeNumber != null) "series" else "movie",
@ -166,11 +248,16 @@ class HomeScreenTest {
lastPositionMs = if (seasonNumber != null && episodeNumber != null) 120_000L else 60_000L,
durationMs = 1_000_000L,
lastUpdatedEpochMs = lastUpdatedEpochMs,
isCompleted = isCompleted,
)
private fun continueWatchingItem(
videoId: String,
subtitle: String,
seasonNumber: Int? = 1,
episodeNumber: Int? = 4,
seedSeasonNumber: Int? = seasonNumber,
seedEpisodeNumber: Int? = episodeNumber,
): ContinueWatchingItem =
ContinueWatchingItem(
parentMetaId = videoId.substringBefore(':'),
@ -179,14 +266,32 @@ class HomeScreenTest {
title = "Show",
subtitle = subtitle,
imageUrl = null,
seasonNumber = 1,
episodeNumber = 4,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
episodeTitle = subtitle.substringAfterLast("", "Episode"),
isNextUp = true,
nextUpSeedSeasonNumber = seedSeasonNumber,
nextUpSeedEpisodeNumber = seedEpisodeNumber,
resumePositionMs = 0L,
durationMs = 0L,
progressFraction = 0f,
)
private fun watchedItem(
id: String,
season: Int,
episode: Int,
markedAtEpochMs: Long,
): WatchedItem =
WatchedItem(
id = id,
type = "series",
name = "Show",
season = season,
episode = episode,
markedAtEpochMs = markedAtEpochMs,
)
private companion object {
const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L
}