diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt index d2210058..9e655f77 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt @@ -142,14 +142,33 @@ internal fun MetaDetails.seriesPrimaryAction( watchedItems: List, 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, + watchedItems: List, + 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 = diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 3bf4715b..6f1c422b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -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>(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, + isTraktProgressActive: Boolean, + daysCap: Int, + nowEpochMs: Long, +): List { + 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, watchedItems: List, isTraktProgressActive: Boolean, preferFurthestEpisode: Boolean, nowEpochMs: Long, -): List { - 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 { + 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 { candidate -> candidate.markedAtEpochMs } + .thenByDescending { candidate -> candidate.seasonNumber } + .thenByDescending { candidate -> candidate.episodeNumber }, + ) +} + +internal fun filterNextUpItemsByCurrentSeeds( + nextUpItemsBySeries: Map>, + activeSeedContentIds: Set, + currentSeedByContentId: Map>, + shouldDropItemsWithoutActiveSeed: Boolean, +): Map> = + 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): List { - val merged = linkedMapOf() - 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, - 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( - { it.seasonNumber ?: -1 }, - { it.episodeNumber ?: -1 }, - { it.lastUpdatedEpochMs }, - ) - } else { - compareBy( - { 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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt index 10263a55..d3c2bc37 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt @@ -41,6 +41,7 @@ fun nextReleasedEpisodeAfter( seasonNumber: Int?, episodeNumber: Int?, todayIsoDate: String, + showUnairedNextUp: Boolean = false, ): WatchingReleasedEpisode? { val sortedEpisodes = episodes.sortedWith( compareBy({ 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, 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 diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt index e5428e16..a70a2627 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt @@ -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( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt index bb98bcbb..0b3d698d 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt @@ -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 }