Try to match CW to the Mobile Implementation

This commit is contained in:
skoruppa 2026-04-02 14:18:46 +02:00
parent baba142275
commit d202c14ba8
2 changed files with 75 additions and 12 deletions

View file

@ -135,8 +135,10 @@ class HomeViewModel @Inject constructor(
internal var pendingExternalMetaPrefetchItemId: String? = null
internal val prefetchedTmdbIds = Collections.synchronizedSet(mutableSetOf<String>())
internal val cwMetaCache = Collections.synchronizedMap(mutableMapOf<String, Meta?>())
internal val cwMetaNegativeCacheTimestamps = Collections.synchronizedMap(mutableMapOf<String, Long>())
internal val cwTmdbIdCache = Collections.synchronizedMap(mutableMapOf<String, String?>())
internal val cwNextUpResolutionCache = Collections.synchronizedMap(mutableMapOf<String, NextUpResolution?>())
internal val cwNextUpNegativeCacheTimestamps = Collections.synchronizedMap(mutableMapOf<String, Long>())
internal val discoveredOlderNextUpItems = Collections.synchronizedList(mutableListOf<ContinueWatchingItem.NextUp>())
internal val fullyWatchedSeriesIds get() = watchedSeriesStateHolder
internal var tmdbEnrichFocusJob: Job? = null

View file

@ -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<Pair<Int, Int>>? = 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<String, Meta?>,
@ -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
}