From 20620dc4008e7d905b731b98b462a7fade83e79b Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:14:54 +0530 Subject: [PATCH] Fix mobile continue watching backfill --- .../com/nuvio/app/features/home/HomeScreen.kt | 71 +++++++++++++- .../nuvio/app/features/home/HomeScreenTest.kt | 94 ++++++++++++++++++- 2 files changed, 163 insertions(+), 2 deletions(-) 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 de8b4983..aa91d463 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 @@ -81,6 +81,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.yield import com.nuvio.app.features.trakt.TraktEpisodeMappingService import com.nuvio.app.features.home.components.ContinueWatchingLayout import com.nuvio.app.features.home.components.continueWatchingLandscapeCardHeight @@ -495,7 +496,9 @@ fun HomeScreen( val candidatesToResolve = completedSeriesCandidates.filter { candidate -> candidate.content.id !in cachedResolvedNextUpItems } - val resolutionCandidates = candidatesToResolve.take(HomeNextUpInitialResolutionLimit) + val resolutionPlan = planHomeNextUpResolutionCandidates(candidatesToResolve) + val resolutionCandidates = resolutionPlan.initialCandidates + val deferredResolutionCandidates = resolutionPlan.deferredCandidates val seedLastWatchedMap = completedSeriesCandidates.associate { it.content.id to it.markedAtEpochMs } if (candidatesToResolve.isEmpty()) { withContext(Dispatchers.Main) { @@ -578,6 +581,59 @@ fun HomeScreen( todayIsoDate = todayIsoDate, seedLastWatchedMap = seedLastWatchedMap, ) + + if (deferredResolutionCandidates.isEmpty()) { + return@withContext + } + + val deferredCandidateBatches = deferredResolutionCandidates.chunked(NEXT_UP_RESOLUTION_BATCH_SIZE) + for (batch in deferredCandidateBatches) { + if (cachedResolvedNextUpItems.size + freshResults.size >= HomeContinueWatchingMaxRecentProgressItems) { + break + } + + val batchResults = batch.map { completedEntry -> + async { + semaphore.withPermit { + resolveHomeNextUpCandidate( + completedEntry = completedEntry, + watchProgressEntries = watchProgressUiState.entries, + watchedItems = watchedUiState.items, + todayIsoDate = todayIsoDate, + preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, + showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp, + dismissedNextUpKeys = continueWatchingPreferences.dismissedNextUpKeys, + isTraktProgressActive = isTraktProgressActive, + ) + } + } + }.awaitAll() + batch.forEach { candidate -> processedFreshContentIds += candidate.content.id } + + val resolvedBeforeBatch = freshResults.size + batchResults.filterNotNull().forEach { (contentId, item) -> + if (cachedResolvedNextUpItems.size + freshResults.size < HomeContinueWatchingMaxRecentProgressItems) { + freshResults[contentId] = item + } + } + if (freshResults.size > resolvedBeforeBatch) { + val progressiveResults = cachedResolvedNextUpItems + freshResults + withContext(Dispatchers.Main) { + nextUpItemsBySeries = progressiveResults + processedNextUpContentIds = ( + cachedResolvedNextUpItems.keys + + processedFreshContentIds + ).toSet() + } + saveContinueWatchingSnapshots( + nextUpItemsBySeries = progressiveResults, + visibleContinueWatchingEntries = visibleContinueWatchingEntries, + todayIsoDate = todayIsoDate, + seedLastWatchedMap = seedLastWatchedMap, + ) + } + yield() + } } } @@ -820,6 +876,19 @@ private const val OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS = 3L * 60L * 1000L private const val NEXT_UP_RESOLUTION_CONCURRENCY = 4 private const val NEXT_UP_RESOLUTION_BATCH_SIZE = NEXT_UP_RESOLUTION_CONCURRENCY +internal data class HomeNextUpResolutionPlan( + val initialCandidates: List, + val deferredCandidates: List, +) + +internal fun planHomeNextUpResolutionCandidates( + candidates: List, +): HomeNextUpResolutionPlan = + HomeNextUpResolutionPlan( + initialCandidates = candidates.take(HomeNextUpInitialResolutionLimit), + deferredCandidates = candidates.drop(HomeNextUpInitialResolutionLimit), + ) + internal fun filterEntriesForTraktContinueWatchingWindow( entries: List, isTraktProgressActive: Boolean, 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 98c2ff52..1f189673 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 @@ -11,6 +11,7 @@ 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 com.nuvio.app.features.watching.domain.WatchingContentRef import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -23,6 +24,18 @@ class HomeScreenTest { assertEquals(32, HomeNextUpInitialResolutionLimit) } + @Test + fun `home next up resolution keeps candidates beyond initial limit for background resolution`() { + val candidates = (1..35).map { index -> completedSeriesCandidate(index) } + + val plan = planHomeNextUpResolutionCandidates(candidates) + + assertEquals(HomeNextUpInitialResolutionLimit, plan.initialCandidates.size) + assertEquals(3, plan.deferredCandidates.size) + assertEquals("show-1", plan.initialCandidates.first().content.id) + assertEquals("show-33", plan.deferredCandidates.first().content.id) + } + @Test fun `build home continue watching items removes duplicate video ids`() { val inProgress = progressEntry( @@ -76,6 +89,33 @@ class HomeScreenTest { assertEquals("S1E5 • The Wolf and the Lion", result.single().subtitle) } + @Test + fun `build home continue watching items keeps deferred next up items with metadata`() { + val nextUpItems = (1..35).associate { index -> + val id = "show-$index" + val item = continueWatchingItem( + videoId = "$id:1:$index", + subtitle = "Next Up • S1E$index", + imageUrl = "https://example.test/$id.jpg", + logo = "https://example.test/$id-logo.png", + episodeThumbnail = "https://example.test/$id-thumb.jpg", + ) + + id to ((10_000L - index) to item) + } + + val result = buildHomeContinueWatchingItems( + visibleEntries = emptyList(), + nextUpItemsBySeries = nextUpItems, + ) + val deferredItem = result.first { item -> item.parentMetaId == "show-33" } + + assertEquals(35, result.size) + assertEquals("https://example.test/show-33.jpg", deferredItem.imageUrl) + assertEquals("https://example.test/show-33-logo.png", deferredItem.logo) + assertEquals("https://example.test/show-33-thumb.jpg", deferredItem.episodeThumbnail) + } + @Test fun `build home continue watching items suppresses next up when series has in progress resume`() { val inProgress = progressEntry( @@ -138,6 +178,45 @@ class HomeScreenTest { assertEquals("GOAT.2026.2160p.UHD.mkv", result.single().title) } + @Test + fun `build home continue watching items preserves cached in progress artwork fallback`() { + val progress = progressEntry( + videoId = "show:1:4", + title = "Show", + lastUpdatedEpochMs = 500L, + ) + val cached = ContinueWatchingItem( + parentMetaId = "show", + parentMetaType = "series", + videoId = "show:1:4", + title = "Cached Show", + subtitle = "S1E4", + imageUrl = "https://example.test/cached.jpg", + logo = "https://example.test/logo.png", + poster = "https://example.test/poster.jpg", + background = "https://example.test/backdrop.jpg", + seasonNumber = 1, + episodeNumber = 4, + episodeTitle = "Cached Episode", + episodeThumbnail = "https://example.test/thumb.jpg", + pauseDescription = "Cached description", + isNextUp = false, + resumePositionMs = 120_000L, + durationMs = 1_000_000L, + progressFraction = 0.12f, + ) + + val result = buildHomeContinueWatchingItems( + visibleEntries = listOf(progress), + cachedInProgressByVideoId = mapOf(progress.videoId 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/thumb.jpg", result.single().episodeThumbnail) + } + @Test fun `Trakt continue watching window filters old progress only when Trakt source is active`() { val oldEntry = progressEntry( @@ -310,6 +389,9 @@ class HomeScreenTest { episodeNumber: Int? = 4, seedSeasonNumber: Int? = seasonNumber, seedEpisodeNumber: Int? = episodeNumber, + imageUrl: String? = null, + logo: String? = null, + episodeThumbnail: String? = null, ): ContinueWatchingItem = ContinueWatchingItem( parentMetaId = videoId.substringBefore(':'), @@ -317,10 +399,12 @@ class HomeScreenTest { videoId = videoId, title = "Show", subtitle = subtitle, - imageUrl = null, + imageUrl = imageUrl, + logo = logo, seasonNumber = seasonNumber, episodeNumber = episodeNumber, episodeTitle = subtitle.substringAfterLast(" • ", "Episode"), + episodeThumbnail = episodeThumbnail, isNextUp = true, nextUpSeedSeasonNumber = seedSeasonNumber, nextUpSeedEpisodeNumber = seedEpisodeNumber, @@ -344,6 +428,14 @@ class HomeScreenTest { markedAtEpochMs = markedAtEpochMs, ) + private fun completedSeriesCandidate(index: Int): CompletedSeriesCandidate = + CompletedSeriesCandidate( + content = WatchingContentRef(type = "series", id = "show-$index"), + seasonNumber = 1, + episodeNumber = index, + markedAtEpochMs = 10_000L - index, + ) + private companion object { const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L }