From d202c14ba8fa7e1a631a40dced66c90e89fdbe41 Mon Sep 17 00:00:00 2001 From: skoruppa Date: Thu, 2 Apr 2026 14:18:46 +0200 Subject: [PATCH] Try to match CW to the Mobile Implementation --- .../nuvio/tv/ui/screens/home/HomeViewModel.kt | 2 + .../home/HomeViewModelContinueWatching.kt | 85 ++++++++++++++++--- 2 files changed, 75 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt index 37cbda1c..6ccbcced 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt @@ -135,8 +135,10 @@ class HomeViewModel @Inject constructor( internal var pendingExternalMetaPrefetchItemId: String? = null internal val prefetchedTmdbIds = Collections.synchronizedSet(mutableSetOf()) internal val cwMetaCache = Collections.synchronizedMap(mutableMapOf()) + internal val cwMetaNegativeCacheTimestamps = Collections.synchronizedMap(mutableMapOf()) internal val cwTmdbIdCache = Collections.synchronizedMap(mutableMapOf()) internal val cwNextUpResolutionCache = Collections.synchronizedMap(mutableMapOf()) + internal val cwNextUpNegativeCacheTimestamps = Collections.synchronizedMap(mutableMapOf()) internal val discoveredOlderNextUpItems = Collections.synchronizedList(mutableListOf()) internal val fullyWatchedSeriesIds get() = watchedSeriesStateHolder internal var tmdbEnrichFocusJob: Job? = null diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelContinueWatching.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelContinueWatching.kt index ca8cf326..26c6293c 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelContinueWatching.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelContinueWatching.kt @@ -35,11 +35,12 @@ import java.time.LocalDateTime import java.time.OffsetDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit import java.util.Locale import java.util.concurrent.atomic.AtomicInteger -private const val CW_MAX_RECENT_PROGRESS_ITEMS = 150 -private const val CW_MAX_NEXT_UP_LOOKUPS = 15 +private const val CW_MAX_RECENT_PROGRESS_ITEMS = 300 +private const val CW_MAX_NEXT_UP_LOOKUPS = 24 private const val CW_MAX_NEXT_UP_CONCURRENCY = 4 private const val CW_MAX_ENRICHMENT_CONCURRENCY = 4 private const val CW_PROGRESS_DEBOUNCE_MS = 500L @@ -984,12 +985,29 @@ private suspend fun HomeViewModel.findNextUpEpisodeFromMetaSeed( synchronized(cwNextUpResolutionCache) { if (cwNextUpResolutionCache.containsKey(cacheKey)) { val cached = cwNextUpResolutionCache[cacheKey] - debug?.recordNextUpCacheHit( - progress = progress, - resolved = cached != null, - showUnairedNextUp = showUnairedNextUp - ) - return cached + if (cached != null) { + debug?.recordNextUpCacheHit( + progress = progress, + resolved = true, + showUnairedNextUp = showUnairedNextUp + ) + return cached + } + // Negative cache entry — check TTL + val negativeCachedAt = cwNextUpNegativeCacheTimestamps[cacheKey] + if (negativeCachedAt != null && + SystemClock.elapsedRealtime() - negativeCachedAt < CW_META_NEGATIVE_CACHE_TTL_MS + ) { + debug?.recordNextUpCacheHit( + progress = progress, + resolved = false, + showUnairedNextUp = showUnairedNextUp + ) + return null + } + // TTL expired — retry + cwNextUpResolutionCache.remove(cacheKey) + cwNextUpNegativeCacheTimestamps.remove(cacheKey) } } val contentId = progress.contentId @@ -1008,6 +1026,7 @@ private suspend fun HomeViewModel.findNextUpEpisodeFromMetaSeed( ) synchronized(cwNextUpResolutionCache) { cwNextUpResolutionCache[cacheKey] = null + cwNextUpNegativeCacheTimestamps[cacheKey] = SystemClock.elapsedRealtime() } return null } @@ -1022,10 +1041,15 @@ private suspend fun HomeViewModel.findNextUpEpisodeFromMetaSeed( logNextUpDecision("drop contentId=$contentId name=${progress.name} reason=no-meta-for-seed") synchronized(cwNextUpResolutionCache) { cwNextUpResolutionCache[cacheKey] = null + cwNextUpNegativeCacheTimestamps[cacheKey] = SystemClock.elapsedRealtime() } return null } - val nextVideo = resolveNextUpVideoFromMeta(progress, meta, showUnairedNextUp) ?: run { + val nextVideo = run { + // Fetch watched episodes for this show to skip already-watched episodes (matching mobile) + val watchedEpisodes = watchProgressRepository.getWatchedShowEpisodes()[contentId] + resolveNextUpVideoFromMeta(progress, meta, showUnairedNextUp, watchedEpisodes) + } ?: run { debug?.recordNextUpResult( progress = progress, reason = "no-next-video-after-seed", @@ -1034,6 +1058,7 @@ private suspend fun HomeViewModel.findNextUpEpisodeFromMetaSeed( ) synchronized(cwNextUpResolutionCache) { cwNextUpResolutionCache[cacheKey] = null + cwNextUpNegativeCacheTimestamps[cacheKey] = SystemClock.elapsedRealtime() } return null } @@ -1078,10 +1103,13 @@ private fun resolveNextUpVideoFromMeta( meta: Meta ): Video? = resolveNextUpVideoFromMeta(progress, meta, showUnairedNextUp = true) +private const val CW_NEXT_UP_NEW_SEASON_UNAIRED_WINDOW_DAYS = 7 + private fun resolveNextUpVideoFromMeta( progress: WatchProgress, meta: Meta, - showUnairedNextUp: Boolean + showUnairedNextUp: Boolean, + watchedEpisodes: Set>? = null ): Video? { val episodes = meta.videos .filter { video -> @@ -1107,6 +1135,13 @@ private fun resolveNextUpVideoFromMeta( val todayLocal = LocalDate.now(ZoneId.systemDefault()) val nextVideo = episodes.drop(watchedIndex + 1).firstOrNull { video -> + // Skip already-watched episodes (matching mobile's isAlreadyWatched) + if (watchedEpisodes != null && video.season != null && video.episode != null) { + if ((video.season!! to video.episode!!) in watchedEpisodes) { + return@firstOrNull false + } + } + val releaseDate = parseEpisodeReleaseDate(video.released) val isSeasonRollover = video.season != seedSeason if (isSeasonRollover) { @@ -1120,6 +1155,13 @@ private fun resolveNextUpVideoFromMeta( if (!releaseDate.isAfter(todayLocal)) { return@firstOrNull true } + // Match mobile: show unaired next-season episodes within 7-day window + if (showUnairedNextUp) { + val daysUntil = java.time.temporal.ChronoUnit.DAYS.between(todayLocal, releaseDate) + if (daysUntil <= CW_NEXT_UP_NEW_SEASON_UNAIRED_WINDOW_DAYS) { + return@firstOrNull true + } + } return@firstOrNull false } @@ -1143,6 +1185,8 @@ private fun resolveNextUpVideoFromMeta( return nextVideo } +private const val CW_META_NEGATIVE_CACHE_TTL_MS = 5 * 60_000L + private suspend fun HomeViewModel.resolveMetaForProgress( progress: WatchProgress, metaCache: MutableMap, @@ -1152,8 +1196,20 @@ private suspend fun HomeViewModel.resolveMetaForProgress( val cacheKey = "${progress.contentType}:${progress.contentId}" synchronized(metaCache) { if (metaCache.containsKey(cacheKey)) { - debug?.recordMetaCacheHit(progress) - return metaCache[cacheKey] + val cached = metaCache[cacheKey] + if (cached != null) { + debug?.recordMetaCacheHit(progress) + return cached + } + val negativeCachedAt = cwMetaNegativeCacheTimestamps[cacheKey] + if (negativeCachedAt != null && + SystemClock.elapsedRealtime() - negativeCachedAt < CW_META_NEGATIVE_CACHE_TTL_MS + ) { + debug?.recordMetaCacheHit(progress) + return null + } + metaCache.remove(cacheKey) + cwMetaNegativeCacheTimestamps.remove(cacheKey) } } @@ -1227,6 +1283,11 @@ private suspend fun HomeViewModel.resolveMetaForProgress( synchronized(metaCache) { metaCache[cacheKey] = resolved + if (resolved == null) { + cwMetaNegativeCacheTimestamps[cacheKey] = SystemClock.elapsedRealtime() + } else { + cwMetaNegativeCacheTimestamps.remove(cacheKey) + } } return resolved }