Fix mobile continue watching backfill

This commit is contained in:
tapframe 2026-06-18 17:14:54 +05:30
parent 938f871a91
commit 20620dc400
2 changed files with 163 additions and 2 deletions

View file

@ -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<CompletedSeriesCandidate>,
val deferredCandidates: List<CompletedSeriesCandidate>,
)
internal fun planHomeNextUpResolutionCandidates(
candidates: List<CompletedSeriesCandidate>,
): HomeNextUpResolutionPlan =
HomeNextUpResolutionPlan(
initialCandidates = candidates.take(HomeNextUpInitialResolutionLimit),
deferredCandidates = candidates.drop(HomeNextUpInitialResolutionLimit),
)
internal fun filterEntriesForTraktContinueWatchingWindow(
entries: List<WatchProgressEntry>,
isTraktProgressActive: Boolean,

View file

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