fix: multiple trakt cw stale cache and pull logic

This commit is contained in:
tapframe 2026-05-19 01:11:16 +05:30
parent 4094151108
commit cff9512d47
5 changed files with 1027 additions and 101 deletions

View file

@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.touchlab.kermit.Logger
import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.network.NetworkCondition
import com.nuvio.app.core.network.NetworkStatusRepository import com.nuvio.app.core.network.NetworkStatusRepository
import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding
@ -35,6 +36,7 @@ import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap
import com.nuvio.app.features.trakt.shouldUseTraktProgress import com.nuvio.app.features.trakt.shouldUseTraktProgress
import com.nuvio.app.features.watched.WatchedItem
import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watchprogress.CachedInProgressItem import com.nuvio.app.features.watchprogress.CachedInProgressItem
import com.nuvio.app.features.watchprogress.CachedNextUpItem import com.nuvio.app.features.watchprogress.CachedNextUpItem
@ -45,13 +47,19 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode
import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching
import com.nuvio.app.features.watchprogress.nextUpDismissKey import com.nuvio.app.features.watchprogress.nextUpDismissKey
import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching
import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueWatching
import com.nuvio.app.features.watchprogress.WatchProgressClock import com.nuvio.app.features.watchprogress.WatchProgressClock
import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.WatchProgressRepository 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.buildContinueWatchingEpisodeSubtitle
import com.nuvio.app.features.watchprogress.continueWatchingEntries
import com.nuvio.app.features.watchprogress.toContinueWatchingItem import com.nuvio.app.features.watchprogress.toContinueWatchingItem
import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem 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.WatchingContentRef
import com.nuvio.app.features.watching.domain.isReleasedBy import com.nuvio.app.features.watching.domain.isReleasedBy
import com.nuvio.app.features.collection.CollectionRepository import com.nuvio.app.features.collection.CollectionRepository
@ -164,46 +172,100 @@ fun HomeScreen(
if (isTraktProgressActive) emptyList() else watchedUiState.items if (isTraktProgressActive) emptyList() else watchedUiState.items
} }
val latestCompletedBySeries = remember(effectiveWatchProgressEntries, effectiveWatchedItems, continueWatchingPreferences.upNextFromFurthestEpisode) { val allNextUpSeedEntries = remember(
WatchingState.latestCompletedBySeries( watchProgressUiState.entries,
progressEntries = effectiveWatchProgressEntries, effectiveWatchedItems,
isTraktProgressActive,
continueWatchingPreferences.upNextFromFurthestEpisode,
) {
buildTvParityNextUpSeedEntries(
progressEntries = watchProgressUiState.entries,
watchedItems = effectiveWatchedItems, watchedItems = effectiveWatchedItems,
isTraktProgressActive = isTraktProgressActive,
preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode,
nowEpochMs = WatchProgressClock.nowEpochMs(),
) )
} }
val completedSeriesCandidates = remember(latestCompletedBySeries) {
latestCompletedBySeries.map { (content, completed) -> val recentNextUpSeedEntries = remember(
allNextUpSeedEntries,
isTraktProgressActive,
traktSettingsUiState.continueWatchingDaysCap,
) {
filterEntriesForTraktContinueWatchingWindow(
entries = allNextUpSeedEntries,
isTraktProgressActive = isTraktProgressActive,
daysCap = traktSettingsUiState.continueWatchingDaysCap,
nowEpochMs = WatchProgressClock.nowEpochMs(),
)
}
val activeNextUpSeedContentIds = remember(allNextUpSeedEntries) {
allNextUpSeedEntries.mapTo(mutableSetOf()) { entry -> entry.parentMetaId }
}
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)
}.toMap()
}
val visibleContinueWatchingEntries = remember(effectiveWatchProgressEntries) {
effectiveWatchProgressEntries.continueWatchingEntries()
}
val latestCompletedAtBySeries = remember(allNextUpSeedEntries) {
allNextUpSeedEntries
.groupBy { entry -> entry.parentMetaId }
.mapValues { (_, entries) -> entries.maxOfOrNull { entry -> entry.lastUpdatedEpochMs } ?: Long.MIN_VALUE }
}
val nextUpSuppressedSeriesIds = remember(visibleContinueWatchingEntries, latestCompletedAtBySeries) {
visibleContinueWatchingEntries
.asSequence()
.filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() }
.filter { entry ->
shouldTreatAsActiveInProgressForNextUpSuppression(
progress = entry,
latestCompletedAt = latestCompletedAtBySeries[entry.parentMetaId],
)
}
.map { entry -> entry.parentMetaId }
.filter(String::isNotBlank)
.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( CompletedSeriesCandidate(
content = content, content = WatchingContentRef(type = seed.parentMetaType, id = seed.parentMetaId),
seasonNumber = completed.seasonNumber, seasonNumber = season,
episodeNumber = completed.episodeNumber, episodeNumber = episode,
markedAtEpochMs = completed.markedAtEpochMs, markedAtEpochMs = seed.lastUpdatedEpochMs,
) )
} }
} }
val completedSeriesContentIds = remember(completedSeriesCandidates) {
completedSeriesCandidates.mapTo(mutableSetOf()) { candidate -> candidate.content.id }
}
val visibleContinueWatchingEntries = remember(
effectiveWatchProgressEntries,
latestCompletedBySeries,
) {
WatchingState.visibleContinueWatchingEntries(
progressEntries = effectiveWatchProgressEntries,
latestCompletedBySeries = latestCompletedBySeries,
)
}
val profileState by ProfileRepository.state.collectAsStateWithLifecycle() val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
val activeProfileId = profileState.activeProfile?.profileIndex ?: 1 val activeProfileId = profileState.activeProfile?.profileIndex ?: 1
var nextUpItemsBySeries by remember(activeProfileId) { mutableStateOf<Map<String, Pair<Long, ContinueWatchingItem>>>(emptyMap()) } var nextUpItemsBySeries by remember(activeProfileId) { mutableStateOf<Map<String, Pair<Long, ContinueWatchingItem>>>(emptyMap()) }
var processedNextUpContentIds by remember(activeProfileId) { mutableStateOf<Set<String>>(emptySet()) }
val cachedSnapshots = remember(activeProfileId) { ContinueWatchingEnrichmentCache.getSnapshots() } val cachedSnapshots = remember(activeProfileId) { ContinueWatchingEnrichmentCache.getSnapshots() }
val cachedNextUpItems = remember( val cachedNextUpItems = remember(
cachedSnapshots.first, cachedSnapshots.first,
continueWatchingPreferences.dismissedNextUpKeys, continueWatchingPreferences.dismissedNextUpKeys,
completedSeriesContentIds, activeNextUpSeedContentIds,
currentNextUpSeedByContentId,
isTraktProgressActive, isTraktProgressActive,
watchProgressUiState.hasLoadedRemoteProgress,
processedNextUpContentIds,
nextUpItemsBySeries,
continueWatchingPreferences.showUnairedNextUp, continueWatchingPreferences.showUnairedNextUp,
watchedUiState.isLoaded, watchedUiState.isLoaded,
) { ) {
@ -211,7 +273,32 @@ fun HomeScreen(
if ( if (
!isTraktProgressActive && !isTraktProgressActive &&
watchedUiState.isLoaded && watchedUiState.isLoaded &&
cached.contentId !in completedSeriesContentIds cached.contentId !in activeNextUpSeedContentIds
) {
return@mapNotNull null
}
if (
isTraktProgressActive &&
watchProgressUiState.hasLoadedRemoteProgress &&
cached.contentId !in activeNextUpSeedContentIds
) {
return@mapNotNull null
}
val currentSeed = currentNextUpSeedByContentId[cached.contentId]
if (
currentSeed != null &&
cached.seedSeason != null &&
cached.seedEpisode != null
) {
val (currentSeason, currentEpisode) = currentSeed
val seedChanged = currentSeason != cached.seedSeason || currentEpisode != cached.seedEpisode
if (seedChanged) return@mapNotNull null
}
if (
isTraktProgressActive &&
watchProgressUiState.hasLoadedRemoteProgress &&
cached.contentId in processedNextUpContentIds &&
cached.contentId !in nextUpItemsBySeries.keys
) { ) {
return@mapNotNull null return@mapNotNull null
} }
@ -257,16 +344,55 @@ fun HomeScreen(
visibleContinueWatchingEntries, visibleContinueWatchingEntries,
cachedInProgressItems, cachedInProgressItems,
effectivNextUpItems, effectivNextUpItems,
nextUpSuppressedSeriesIds,
continueWatchingPreferences.sortMode, continueWatchingPreferences.sortMode,
) { ) {
buildHomeContinueWatchingItems( buildHomeContinueWatchingItems(
visibleEntries = visibleContinueWatchingEntries, visibleEntries = visibleContinueWatchingEntries,
cachedInProgressByVideoId = cachedInProgressItems, cachedInProgressByVideoId = cachedInProgressItems,
nextUpItemsBySeries = effectivNextUpItems, nextUpItemsBySeries = effectivNextUpItems,
nextUpSuppressedSeriesIds = nextUpSuppressedSeriesIds,
sortMode = continueWatchingPreferences.sortMode, sortMode = continueWatchingPreferences.sortMode,
todayIsoDate = CurrentDateProvider.todayIsoDate(), todayIsoDate = CurrentDateProvider.todayIsoDate(),
) )
} }
LaunchedEffect(
isTraktProgressActive,
traktSettingsUiState.continueWatchingDaysCap,
watchProgressUiState.hasLoadedRemoteProgress,
continueWatchingPreferences.upNextFromFurthestEpisode,
watchProgressUiState.entries,
effectiveWatchProgressEntries,
allNextUpSeedEntries,
recentNextUpSeedEntries,
nextUpSuppressedSeriesIds,
visibleContinueWatchingEntries,
completedSeriesCandidates,
cachedInProgressItems,
cachedNextUpItems,
nextUpItemsBySeries,
processedNextUpContentIds,
effectivNextUpItems,
continueWatchingItems,
) {
homeCwLog.d {
"build summary source=${if (isTraktProgressActive) "trakt" else "nuvio_sync"} " +
"remoteLoaded=${watchProgressUiState.hasLoadedRemoteProgress} " +
"daysCap=${traktSettingsUiState.continueWatchingDaysCap} " +
"raw=${watchProgressUiState.entries.size} rawSources=${watchProgressUiState.entries.debugSourceCounts()} " +
"effective=${effectiveWatchProgressEntries.size} seedAll=${allNextUpSeedEntries.size} " +
"seedRecent=${recentNextUpSeedEntries.size} seedSuppressed=${nextUpSuppressedSeriesIds.size} " +
"useFurthest=${continueWatchingPreferences.upNextFromFurthestEpisode} " +
"visibleInProgress=${visibleContinueWatchingEntries.size} " +
"completedCandidates=${completedSeriesCandidates.size} cachedInProgress=${cachedInProgressItems.size} " +
"cachedNextUp=${cachedNextUpItems.size} liveNextUp=${nextUpItemsBySeries.size} " +
"processedNextUp=${processedNextUpContentIds.size} " +
"effectiveNextUp=${effectivNextUpItems.size} final=${continueWatchingItems.size} " +
"rawItems=${watchProgressUiState.entries.debugWatchProgressSummary()} " +
"completed=${completedSeriesCandidates.debugCompletedSeriesSummary()} " +
"finalItems=${continueWatchingItems.debugContinueWatchingSummary()}"
}
}
val availableManifests = remember(addonsUiState.addons) { val availableManifests = remember(addonsUiState.addons) {
addonsUiState.addons.mapNotNull { addon -> addon.manifest } addonsUiState.addons.mapNotNull { addon -> addon.manifest }
} }
@ -308,37 +434,75 @@ fun HomeScreen(
continueWatchingPreferences.showUnairedNextUp, continueWatchingPreferences.showUnairedNextUp,
) { ) {
if (completedSeriesCandidates.isEmpty()) { if (completedSeriesCandidates.isEmpty()) {
homeCwLog.d {
"next-up resolve skipped: no completed series candidates " +
"entries=${effectiveWatchProgressEntries.size} sources=${effectiveWatchProgressEntries.debugSourceCounts()}"
}
nextUpItemsBySeries = emptyMap() nextUpItemsBySeries = emptyMap()
processedNextUpContentIds = emptySet()
return@LaunchedEffect return@LaunchedEffect
} }
if (metaProviderKey.isEmpty()) return@LaunchedEffect if (metaProviderKey.isEmpty()) {
homeCwLog.d {
"next-up resolve deferred: no meta providers candidates=${completedSeriesCandidates.size} " +
"candidates=${completedSeriesCandidates.debugCompletedSeriesSummary()}"
}
return@LaunchedEffect
}
val todayIsoDate = CurrentDateProvider.todayIsoDate() val todayIsoDate = CurrentDateProvider.todayIsoDate()
val semaphore = Semaphore(4) val semaphore = Semaphore(4)
homeCwLog.d {
"next-up resolve start candidates=${completedSeriesCandidates.size} " +
"showUnaired=${continueWatchingPreferences.showUnairedNextUp} " +
"metaProviders=${metaProviderKey.size} candidates=${completedSeriesCandidates.debugCompletedSeriesSummary()}"
}
val results = completedSeriesCandidates.map { completedEntry -> val results = completedSeriesCandidates.map { completedEntry ->
async { async {
semaphore.withPermit { semaphore.withPermit {
val meta = MetaDetailsRepository.fetch( val meta = MetaDetailsRepository.fetch(
type = completedEntry.content.type, type = completedEntry.content.type,
id = completedEntry.content.id, id = completedEntry.content.id,
) ?: return@withPermit null )
if (meta == null) {
homeCwLog.d {
"next-up meta miss content=${completedEntry.debugSummary()} " +
"type=${completedEntry.content.type} id=${completedEntry.content.id}"
}
return@withPermit null
}
val nextEpisode = meta.nextReleasedEpisodeAfter( val nextEpisode = meta.nextReleasedEpisodeAfter(
seasonNumber = completedEntry.seasonNumber, seasonNumber = completedEntry.seasonNumber,
episodeNumber = completedEntry.episodeNumber, episodeNumber = completedEntry.episodeNumber,
todayIsoDate = todayIsoDate, todayIsoDate = todayIsoDate,
showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp, showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp,
) ?: return@withPermit null )
if (nextEpisode == null) {
homeCwLog.d {
"next-up no next episode content=${completedEntry.debugSummary()} " +
"videos=${meta.videos.size}"
}
return@withPermit null
}
val item = completedEntry.toContinueWatchingSeed(meta) val item = completedEntry.toContinueWatchingSeed(meta)
.toUpNextContinueWatchingItem(nextEpisode) .toUpNextContinueWatchingItem(nextEpisode)
if (nextUpDismissKey(item.parentMetaId, item.nextUpSeedSeasonNumber, item.nextUpSeedEpisodeNumber) in continueWatchingPreferences.dismissedNextUpKeys) { if (nextUpDismissKey(item.parentMetaId, item.nextUpSeedSeasonNumber, item.nextUpSeedEpisodeNumber) in continueWatchingPreferences.dismissedNextUpKeys) {
homeCwLog.d { "next-up dismissed item=${item.debugSummary()}" }
return@withPermit null return@withPermit null
} }
homeCwLog.d {
"next-up built seed=${completedEntry.debugSummary()} item=${item.debugSummary()} " +
"released=${nextEpisode.released}"
}
completedEntry.content.id to (completedEntry.markedAtEpochMs to item) completedEntry.content.id to (completedEntry.markedAtEpochMs to item)
} }
} }
}.awaitAll().filterNotNull().toMap() }.awaitAll().filterNotNull().toMap()
nextUpItemsBySeries = results nextUpItemsBySeries = results
processedNextUpContentIds = completedSeriesCandidates.mapTo(mutableSetOf()) { candidate ->
candidate.content.id
}
val nextUpCache = results.mapNotNull { (contentId, pair) -> val nextUpCache = results.mapNotNull { (contentId, pair) ->
val item = pair.second val item = pair.second
@ -389,6 +553,10 @@ fun HomeScreen(
nextUp = nextUpCache, nextUp = nextUpCache,
inProgress = inProgressCache, inProgress = inProgressCache,
) )
homeCwLog.d {
"next-up resolve complete results=${results.size} nextUpCache=${nextUpCache.size} " +
"inProgressCache=${inProgressCache.size} items=${results.values.map { it.second }.debugContinueWatchingSummary()}"
}
} }
val hasActiveAddons = addonsUiState.addons.any { it.manifest != null } val hasActiveAddons = addonsUiState.addons.any { it.manifest != null }
@ -615,6 +783,8 @@ fun HomeScreen(
private const val HOME_CATALOG_PREVIEW_LIMIT = 18 private const val HOME_CATALOG_PREVIEW_LIMIT = 18
private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L
private const val OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS = 3L * 60L * 1000L
private val homeCwLog = Logger.withTag("HomeCW")
internal fun filterEntriesForTraktContinueWatchingWindow( internal fun filterEntriesForTraktContinueWatchingWindow(
entries: List<WatchProgressEntry>, entries: List<WatchProgressEntry>,
@ -630,6 +800,169 @@ internal fun filterEntriesForTraktContinueWatchingWindow(
return entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs } return entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs }
} }
private fun buildTvParityNextUpSeedEntries(
progressEntries: List<WatchProgressEntry>,
watchedItems: List<WatchedItem>,
isTraktProgressActive: Boolean,
preferFurthestEpisode: Boolean,
nowEpochMs: Long,
): List<WatchProgressEntry> {
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()
}
return if (isTraktProgressActive) {
mergeTvTraktNextUpSeeds(rawSeeds)
} else {
rawSeeds
.groupBy { entry -> nextUpSeedKey(entry) }
.mapNotNull { (_, entries) ->
choosePreferredNextUpSeed(
entries = entries,
preferFurthestEpisode = preferFurthestEpisode,
)
}
.sortedByDescending { entry -> entry.lastUpdatedEpochMs }
}
}
private fun shouldUseAsTraktNextUpSeed(
entry: WatchProgressEntry,
nowEpochMs: Long,
): Boolean {
if (!entry.shouldUseAsCompletedSeedForContinueWatching()) return false
if (entry.source != WatchProgressSourceTraktPlayback) return true
val ageMs = nowEpochMs - entry.lastUpdatedEpochMs
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<WatchProgressEntry>): List<WatchProgressEntry> {
val merged = linkedMapOf<String, WatchProgressEntry>()
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<WatchProgressEntry>,
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<WatchProgressEntry>(
{ it.seasonNumber ?: -1 },
{ it.episodeNumber ?: -1 },
{ it.lastUpdatedEpochMs },
)
} else {
compareBy<WatchProgressEntry>(
{ 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?,
): Boolean {
if (!progress.shouldTreatAsInProgressForContinueWatching()) return false
if (latestCompletedAt == null || latestCompletedAt == Long.MIN_VALUE) return true
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( private fun heroMobileBelowSectionHeightHint(
maxWidthDp: Float, maxWidthDp: Float,
continueWatchingVisible: Boolean, continueWatchingVisible: Boolean,
@ -652,15 +985,17 @@ internal fun buildHomeContinueWatchingItems(
visibleEntries: List<WatchProgressEntry>, visibleEntries: List<WatchProgressEntry>,
cachedInProgressByVideoId: Map<String, ContinueWatchingItem> = emptyMap(), cachedInProgressByVideoId: Map<String, ContinueWatchingItem> = emptyMap(),
nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>, nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>,
nextUpSuppressedSeriesIds: Set<String>? = null,
sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT, sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT,
todayIsoDate: String = "", todayIsoDate: String = "",
): List<ContinueWatchingItem> { ): List<ContinueWatchingItem> {
val inProgressSeriesIds = visibleEntries val suppressedSeriesIds = nextUpSuppressedSeriesIds
.asSequence() ?: visibleEntries
.filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() } .asSequence()
.map { entry -> entry.parentMetaId } .filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() }
.filter(String::isNotBlank) .map { entry -> entry.parentMetaId }
.toSet() .filter(String::isNotBlank)
.toSet()
val candidates = buildList { val candidates = buildList {
addAll( addAll(
@ -675,7 +1010,7 @@ internal fun buildHomeContinueWatchingItems(
) )
addAll( addAll(
nextUpItemsBySeries.values.mapNotNull { (lastUpdatedEpochMs, item) -> nextUpItemsBySeries.values.mapNotNull { (lastUpdatedEpochMs, item) ->
if (item.parentMetaId in inProgressSeriesIds) return@mapNotNull null if (item.parentMetaId in suppressedSeriesIds) return@mapNotNull null
HomeContinueWatchingCandidate( HomeContinueWatchingCandidate(
lastUpdatedEpochMs = lastUpdatedEpochMs, lastUpdatedEpochMs = lastUpdatedEpochMs,
item = item, item = item,
@ -866,3 +1201,83 @@ private fun ContinueWatchingItem.withFallbackMetadata(
released = released ?: fallback.released, released = released ?: fallback.released,
) )
} }
private fun WatchProgressEntry.debugSummary(): String =
buildString {
append(parentMetaType)
append(":")
append(parentMetaId)
if (seasonNumber != null || episodeNumber != null) {
append(" s=")
append(seasonNumber)
append(" e=")
append(episodeNumber)
}
append(" video=")
append(videoId)
append(" pct=")
append(progressPercent)
append(" completed=")
append(isCompleted)
append(" effectiveCompleted=")
append(isEffectivelyCompleted)
append(" src=")
append(source)
append(" last=")
append(lastUpdatedEpochMs)
}
private fun Collection<WatchProgressEntry>.debugWatchProgressSummary(limit: Int = 10): String =
take(limit).joinToString(separator = " | ") { it.debugSummary() }.ifBlank { "none" }
private fun Collection<WatchProgressEntry>.debugSourceCounts(): String =
groupingBy { it.source }
.eachCount()
.entries
.sortedBy { it.key }
.joinToString(separator = ",") { "${it.key}=${it.value}" }
.ifBlank { "none" }
private fun CompletedSeriesCandidate.debugSummary(): String =
buildString {
append(content.type)
append(":")
append(content.id)
append(" s=")
append(seasonNumber)
append(" e=")
append(episodeNumber)
append(" marked=")
append(markedAtEpochMs)
}
private fun Collection<CompletedSeriesCandidate>.debugCompletedSeriesSummary(limit: Int = 10): String =
take(limit).joinToString(separator = " | ") { it.debugSummary() }.ifBlank { "none" }
private fun ContinueWatchingItem.debugSummary(): String =
buildString {
append(if (isNextUp) "next_up" else "in_progress")
append(":")
append(parentMetaType)
append(":")
append(parentMetaId)
if (seasonNumber != null || episodeNumber != null) {
append(" s=")
append(seasonNumber)
append(" e=")
append(episodeNumber)
}
append(" video=")
append(videoId)
append(" seed=")
append(nextUpSeedSeasonNumber)
append("x")
append(nextUpSeedEpisodeNumber)
append(" progress=")
append(progressFraction)
append(" resume=")
append(resumePositionMs)
}
private fun Collection<ContinueWatchingItem>.debugContinueWatchingSummary(limit: Int = 10): String =
take(limit).joinToString(separator = " | ") { it.debugSummary() }.ifBlank { "none" }

View file

@ -13,6 +13,7 @@ import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async import kotlinx.coroutines.async
@ -22,7 +23,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
@ -44,6 +47,7 @@ data class TraktProgressUiState(
val entries: List<WatchProgressEntry> = emptyList(), val entries: List<WatchProgressEntry> = emptyList(),
val isLoading: Boolean = false, val isLoading: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null,
val hasLoadedRemoteProgress: Boolean = false,
) )
object TraktProgressRepository { object TraktProgressRepository {
@ -56,6 +60,8 @@ object TraktProgressRepository {
private var hasLoaded = false private var hasLoaded = false
private var refreshRequestId: Long = 0L private var refreshRequestId: Long = 0L
private val refreshJobMutex = Mutex()
private var inFlightRefresh: Deferred<Unit>? = null
fun ensureLoaded() { fun ensureLoaded() {
if (hasLoaded) return if (hasLoaded) return
@ -82,14 +88,35 @@ object TraktProgressRepository {
} }
suspend fun refreshNow() { suspend fun refreshNow() {
ensureLoaded()
val refresh = refreshJobMutex.withLock {
inFlightRefresh?.takeIf { it.isActive } ?: scope.async {
refreshNowInternal()
}.also { inFlightRefresh = it }
}
try {
refresh.await()
} finally {
refreshJobMutex.withLock {
if (inFlightRefresh == refresh && refresh.isCompleted) {
inFlightRefresh = null
}
}
}
}
private suspend fun refreshNowInternal() {
ensureLoaded() ensureLoaded()
val requestId = nextRefreshRequestId() val requestId = nextRefreshRequestId()
val headers = TraktAuthRepository.authorizedHeaders() val headers = TraktAuthRepository.authorizedHeaders()
if (headers == null) { if (headers == null) {
log.d { "refreshNow request=$requestId skipped: missing authorized headers" }
_uiState.value = TraktProgressUiState() _uiState.value = TraktProgressUiState()
return return
} }
log.d { "refreshNow request=$requestId start currentEntries=${_uiState.value.entries.size}" }
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
val playbackEntries = runCatching { val playbackEntries = runCatching {
@ -109,34 +136,56 @@ object TraktProgressRepository {
_uiState.value = TraktProgressUiState( _uiState.value = TraktProgressUiState(
entries = playbackEntries, entries = playbackEntries,
isLoading = false, isLoading = true,
errorMessage = null, errorMessage = null,
hasLoadedRemoteProgress = false,
) )
log.d {
"refreshNow request=$requestId playback applied entries=${playbackEntries.size} " +
"sources=${playbackEntries.debugSourceCounts()} items=${playbackEntries.debugWatchProgressSummary()}"
}
if (playbackEntries.isNotEmpty()) { if (playbackEntries.isNotEmpty()) {
launchHydration(requestId = requestId, entries = playbackEntries) launchHydration(requestId = requestId, entries = playbackEntries)
} }
scope.launch { val completedEntries = runCatching {
val completedEntries = runCatching { coroutineScope {
fetchHistoryEntries(headers) + fetchWatchedShowSeedEntries(headers) val history = async { fetchHistoryEntries(headers) }
}.onFailure { error -> val watchedShowSeeds = async { fetchWatchedShowSeedEntries(headers) }
if (error is CancellationException) throw error history.await() + watchedShowSeeds.await()
log.w { "Failed to fetch Trakt history snapshot: ${error.message}" } }
}.getOrNull() ?: return@launch }.onFailure { error ->
if (error is CancellationException) throw error
log.w { "Failed to fetch Trakt history snapshot: ${error.message}" }
}.getOrNull()
if (!isLatestRefreshRequest(requestId)) return@launch if (completedEntries == null) {
val merged = mergeNewestByVideoId(playbackEntries + completedEntries)
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
entries = merged.sortedByDescending { it.lastUpdatedEpochMs },
isLoading = false, isLoading = false,
errorMessage = null, errorMessage = null,
hasLoadedRemoteProgress = false,
) )
return
}
if (merged.isNotEmpty()) { if (!isLatestRefreshRequest(requestId)) return
launchHydration(requestId = requestId, entries = merged)
} val merged = mergeNewestByVideoId(playbackEntries + completedEntries)
_uiState.value = _uiState.value.copy(
entries = merged.sortedByDescending { it.lastUpdatedEpochMs },
isLoading = false,
errorMessage = null,
hasLoadedRemoteProgress = true,
)
log.d {
"refreshNow request=$requestId completed snapshot applied " +
"completedEntries=${completedEntries.size} merged=${merged.size} " +
"sources=${merged.debugSourceCounts()} items=${merged.debugWatchProgressSummary()}"
}
if (merged.isNotEmpty()) {
launchHydration(requestId = requestId, entries = merged)
} }
} }
@ -163,6 +212,10 @@ object TraktProgressRepository {
isLoading = false, isLoading = false,
errorMessage = null, errorMessage = null,
) )
log.d {
"hydrate request=$requestId applied hydrated=${hydrated.size} merged=${merged.size} " +
"items=${merged.debugWatchProgressSummary()}"
}
} }
} }
@ -175,6 +228,10 @@ object TraktProgressRepository {
current[normalizedEntry.videoId] = normalizedEntry current[normalizedEntry.videoId] = normalizedEntry
} }
_uiState.value = _uiState.value.copy(entries = current.values.sortedByDescending { it.lastUpdatedEpochMs }) _uiState.value = _uiState.value.copy(entries = current.values.sortedByDescending { it.lastUpdatedEpochMs })
log.d {
"optimistic progress applied entry=${normalizedEntry.debugSummary()} " +
"entries=${_uiState.value.entries.size}"
}
} }
fun applyOptimisticRemoval(videoId: String) { fun applyOptimisticRemoval(videoId: String) {
@ -182,6 +239,7 @@ object TraktProgressRepository {
if (videoId.isBlank()) return if (videoId.isBlank()) return
val filtered = _uiState.value.entries.filterNot { it.videoId == videoId } val filtered = _uiState.value.entries.filterNot { it.videoId == videoId }
_uiState.value = _uiState.value.copy(entries = filtered) _uiState.value = _uiState.value.copy(entries = filtered)
log.d { "optimistic removal videoId=$videoId entries=${filtered.size}" }
} }
fun applyOptimisticRemoval( fun applyOptimisticRemoval(
@ -202,6 +260,10 @@ object TraktProgressRepository {
} }
} }
_uiState.value = _uiState.value.copy(entries = filtered) _uiState.value = _uiState.value.copy(entries = filtered)
log.d {
"optimistic removal contentId=$normalizedContentId s=$seasonNumber e=$episodeNumber " +
"entries=${filtered.size}"
}
} }
suspend fun removeProgress( suspend fun removeProgress(
@ -213,6 +275,7 @@ object TraktProgressRepository {
if (normalizedContentId.isBlank()) return if (normalizedContentId.isBlank()) return
val headers = TraktAuthRepository.authorizedHeaders() ?: return val headers = TraktAuthRepository.authorizedHeaders() ?: return
log.d { "removeProgress start contentId=$normalizedContentId s=$seasonNumber e=$episodeNumber" }
applyOptimisticRemoval( applyOptimisticRemoval(
contentId = normalizedContentId, contentId = normalizedContentId,
seasonNumber = seasonNumber, seasonNumber = seasonNumber,
@ -280,10 +343,12 @@ object TraktProgressRepository {
} }
} }
log.d { "removeProgress complete contentId=$normalizedContentId refreshing" }
refreshNow() refreshNow()
} }
private suspend fun fetchPlaybackEntries(headers: Map<String, String>): List<WatchProgressEntry> = withContext(Dispatchers.Default) { private suspend fun fetchPlaybackEntries(headers: Map<String, String>): List<WatchProgressEntry> = withContext(Dispatchers.Default) {
log.d { "fetchPlaybackEntries start" }
val payloads = coroutineScope { val payloads = coroutineScope {
val moviesPayload = async { val moviesPayload = async {
httpGetTextWithHeaders( httpGetTextWithHeaders(
@ -306,6 +371,10 @@ object TraktProgressRepository {
val moviePlayback = json.decodeFromString<List<TraktPlaybackItem>>(moviesPayload) val moviePlayback = json.decodeFromString<List<TraktPlaybackItem>>(moviesPayload)
val episodePlayback = json.decodeFromString<List<TraktPlaybackItem>>(episodesPayload) val episodePlayback = json.decodeFromString<List<TraktPlaybackItem>>(episodesPayload)
log.d {
"fetchPlaybackEntries raw movies=${moviePlayback.size} episodes=${episodePlayback.size} " +
"movieItems=${moviePlayback.debugPlaybackSummary()} episodeItems=${episodePlayback.debugPlaybackSummary()}"
}
val inProgressMovies = moviePlayback.mapIndexedNotNull { index, item -> val inProgressMovies = moviePlayback.mapIndexedNotNull { index, item ->
mapPlaybackMovie(item = item, fallbackIndex = index) mapPlaybackMovie(item = item, fallbackIndex = index)
@ -314,10 +383,16 @@ object TraktProgressRepository {
mapPlaybackEpisode(item = item, fallbackIndex = index) mapPlaybackEpisode(item = item, fallbackIndex = index)
} }
mergeNewestByVideoId(inProgressMovies + inProgressEpisodes) val merged = mergeNewestByVideoId(inProgressMovies + inProgressEpisodes)
log.d {
"fetchPlaybackEntries mapped movies=${inProgressMovies.size} episodes=${inProgressEpisodes.size} " +
"merged=${merged.size} items=${merged.debugWatchProgressSummary()}"
}
merged
} }
private suspend fun fetchHistoryEntries(headers: Map<String, String>): List<WatchProgressEntry> = withContext(Dispatchers.Default) { private suspend fun fetchHistoryEntries(headers: Map<String, String>): List<WatchProgressEntry> = withContext(Dispatchers.Default) {
log.d { "fetchHistoryEntries start limit=$HISTORY_LIMIT" }
val payloads = coroutineScope { val payloads = coroutineScope {
val historyPayload = async { val historyPayload = async {
httpGetTextWithHeaders( httpGetTextWithHeaders(
@ -339,6 +414,10 @@ object TraktProgressRepository {
val movieHistoryPayload = payloads[1] val movieHistoryPayload = payloads[1]
val episodeHistory = json.decodeFromString<List<TraktHistoryEpisodeItem>>(historyPayload) val episodeHistory = json.decodeFromString<List<TraktHistoryEpisodeItem>>(historyPayload)
val movieHistory = json.decodeFromString<List<TraktHistoryMovieItem>>(movieHistoryPayload) val movieHistory = json.decodeFromString<List<TraktHistoryMovieItem>>(movieHistoryPayload)
log.d {
"fetchHistoryEntries raw episodes=${episodeHistory.size} movies=${movieHistory.size} " +
"episodeItems=${episodeHistory.debugHistoryEpisodeSummary()} movieItems=${movieHistory.debugHistoryMovieSummary()}"
}
val completedEpisodes = episodeHistory val completedEpisodes = episodeHistory
.mapIndexedNotNull { index, item -> mapHistoryEpisode(item = item, fallbackIndex = index) } .mapIndexedNotNull { index, item -> mapHistoryEpisode(item = item, fallbackIndex = index) }
@ -347,7 +426,12 @@ object TraktProgressRepository {
.mapIndexedNotNull { index, item -> mapHistoryMovie(item = item, fallbackIndex = index) } .mapIndexedNotNull { index, item -> mapHistoryMovie(item = item, fallbackIndex = index) }
.distinctBy { entry -> entry.videoId } .distinctBy { entry -> entry.videoId }
mergeNewestByVideoId(completedEpisodes + completedMovies) val merged = mergeNewestByVideoId(completedEpisodes + completedMovies)
log.d {
"fetchHistoryEntries mapped episodes=${completedEpisodes.size} movies=${completedMovies.size} " +
"merged=${merged.size} items=${merged.debugWatchProgressSummary()}"
}
merged
} }
private suspend fun fetchWatchedShowSeedEntries( private suspend fun fetchWatchedShowSeedEntries(
@ -360,7 +444,11 @@ object TraktProgressRepository {
headers = headers, headers = headers,
) )
val watchedShows = json.decodeFromString<List<TraktWatchedShowItem>>(payload) val watchedShows = json.decodeFromString<List<TraktWatchedShowItem>>(payload)
watchedShows log.d {
"fetchWatchedShowSeedEntries raw shows=${watchedShows.size} " +
"items=${watchedShows.debugWatchedShowSummary()}"
}
val mapped = watchedShows
.mapNotNull { item -> .mapNotNull { item ->
mapWatchedShowSeed( mapWatchedShowSeed(
item = item, item = item,
@ -368,6 +456,11 @@ object TraktProgressRepository {
) )
} }
.sortedByDescending { entry -> entry.lastUpdatedEpochMs } .sortedByDescending { entry -> entry.lastUpdatedEpochMs }
log.d {
"fetchWatchedShowSeedEntries mapped=${mapped.size} useFurthest=$useFurthestEpisode " +
"items=${mapped.debugWatchProgressSummary()}"
}
mapped
} }
private fun mergeNewestByVideoId(entries: List<WatchProgressEntry>): List<WatchProgressEntry> { private fun mergeNewestByVideoId(entries: List<WatchProgressEntry>): List<WatchProgressEntry> {
@ -436,6 +529,8 @@ object TraktProgressRepository {
private fun invalidateInFlightRefreshes() { private fun invalidateInFlightRefreshes() {
refreshRequestId += 1L refreshRequestId += 1L
inFlightRefresh?.cancel()
inFlightRefresh = null
} }
private fun isLatestRefreshRequest(requestId: Long): Boolean = refreshRequestId == requestId private fun isLatestRefreshRequest(requestId: Long): Boolean = refreshRequestId == requestId
@ -819,3 +914,165 @@ private data class TraktEpisode(
@SerialName("number") val number: Int? = null, @SerialName("number") val number: Int? = null,
@SerialName("ids") val ids: TraktExternalIds? = null, @SerialName("ids") val ids: TraktExternalIds? = null,
) )
private fun WatchProgressEntry.debugSummary(): String =
buildString {
append(parentMetaType)
append(":")
append(parentMetaId)
if (seasonNumber != null || episodeNumber != null) {
append(" s=")
append(seasonNumber)
append(" e=")
append(episodeNumber)
}
append(" video=")
append(videoId)
append(" pct=")
append(progressPercent)
append(" completed=")
append(isCompleted)
append(" effectiveCompleted=")
append(isEffectivelyCompleted)
append(" src=")
append(source)
append(" last=")
append(lastUpdatedEpochMs)
}
private fun Collection<WatchProgressEntry>.debugWatchProgressSummary(limit: Int = 10): String =
take(limit).joinToString(separator = " | ") { it.debugSummary() }.ifBlank { "none" }
private fun Collection<WatchProgressEntry>.debugSourceCounts(): String =
groupingBy { it.source }
.eachCount()
.entries
.sortedBy { it.key }
.joinToString(separator = ",") { "${it.key}=${it.value}" }
.ifBlank { "none" }
private fun Collection<TraktPlaybackItem>.debugPlaybackSummary(limit: Int = 8): String =
take(limit).joinToString(separator = " | ") { item ->
val media = item.movie ?: item.show
val episode = item.episode
buildString {
append(media?.title ?: "unknown")
append(" ids=")
append(media?.ids.debugIds())
if (episode != null) {
append(" ep=")
append(episode.season)
append("x")
append(episode.number)
append(" epIds=")
append(episode.ids.debugIds())
}
append(" progress=")
append(item.progress)
append(" pausedAt=")
append(item.pausedAt)
append(" playbackId=")
append(item.id)
}
}.ifBlank { "none" }
private fun Collection<TraktHistoryEpisodeItem>.debugHistoryEpisodeSummary(limit: Int = 8): String =
take(limit).joinToString(separator = " | ") { item ->
buildString {
append(item.show?.title ?: "unknown")
append(" ids=")
append(item.show?.ids.debugIds())
append(" ep=")
append(item.episode?.season)
append("x")
append(item.episode?.number)
append(" epIds=")
append(item.episode?.ids.debugIds())
append(" watchedAt=")
append(item.watchedAt)
}
}.ifBlank { "none" }
private fun Collection<TraktHistoryMovieItem>.debugHistoryMovieSummary(limit: Int = 8): String =
take(limit).joinToString(separator = " | ") { item ->
buildString {
append(item.movie?.title ?: "unknown")
append(" ids=")
append(item.movie?.ids.debugIds())
append(" watchedAt=")
append(item.watchedAt)
}
}.ifBlank { "none" }
private fun Collection<TraktWatchedShowItem>.debugWatchedShowSummary(limit: Int = 8): String =
take(limit).joinToString(separator = " | ") { item ->
val episodeCount = item.seasons.orEmpty().sumOf { season ->
season.episodes.orEmpty().count { episode ->
(episode.number ?: 0) > 0 && (episode.plays ?: 1) > 0
}
}
val latest = item.seasons.orEmpty()
.flatMap { season ->
val seasonNumber = season.number
season.episodes.orEmpty().mapNotNull { episode ->
val episodeNumber = episode.number ?: return@mapNotNull null
val watchedAt = episode.lastWatchedAt ?: item.lastWatchedAt
TraktWatchedShowEpisodeSeed(
season = seasonNumber ?: 0,
episode = episodeNumber,
watchedAt = watchedAt
?.let { value ->
runCatching { TraktPlatformClock.parseIsoDateTimeToEpochMs(value) }.getOrNull()
}
?: 0L,
)
}
}
.maxWithOrNull(
compareBy<TraktWatchedShowEpisodeSeed>(
{ it.watchedAt },
{ it.season },
{ it.episode },
),
)
buildString {
append(item.show?.title ?: "unknown")
append(" ids=")
append(item.show?.ids.debugIds())
append(" episodes=")
append(episodeCount)
append(" latest=")
append(latest?.season)
append("x")
append(latest?.episode)
append(" lastWatchedAt=")
append(item.lastWatchedAt)
}
}.ifBlank { "none" }
private fun TraktExternalIds?.debugIds(): String =
if (this == null) {
"none"
} else {
buildString {
imdb?.takeIf { it.isNotBlank() }?.let {
append("imdb:")
append(it)
}
tmdb?.let {
if (isNotEmpty()) append(",")
append("tmdb:")
append(it)
}
trakt?.let {
if (isNotEmpty()) append(",")
append("trakt:")
append(it)
}
slug?.takeIf { it.isNotBlank() }?.let {
if (isNotEmpty()) append(",")
append("slug:")
append(it)
}
}.ifBlank { "none" }
}

View file

@ -1,5 +1,6 @@
package com.nuvio.app.features.watching.sync package com.nuvio.app.features.watching.sync
import co.touchlab.kermit.Logger
import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.WatchProgressEntry
import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.postgrest
@ -12,12 +13,14 @@ import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
object SupabaseProgressSyncAdapter : ProgressSyncAdapter { object SupabaseProgressSyncAdapter : ProgressSyncAdapter {
private val log = Logger.withTag("NuvioSyncProgress")
private val json = Json { private val json = Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
encodeDefaults = true encodeDefaults = true
} }
override suspend fun pull(profileId: Int): List<ProgressSyncRecord> { override suspend fun pull(profileId: Int): List<ProgressSyncRecord> {
log.d { "pull start profileId=$profileId" }
val params = buildJsonObject { put("p_profile_id", profileId) } val params = buildJsonObject { put("p_profile_id", profileId) }
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_watch_progress", params) val result = SupabaseProvider.client.postgrest.rpc("sync_pull_watch_progress", params)
val serverEntries = result.decodeList<WatchProgressSyncEntry>() val serverEntries = result.decodeList<WatchProgressSyncEntry>()
@ -33,6 +36,10 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter {
lastWatched = entry.lastWatched, lastWatched = entry.lastWatched,
) )
} }
log.d {
"pull returned raw=${serverEntries.size} records=${records.size} " +
"items=${records.debugProgressRecordSummary()}"
}
return records return records
} }
@ -40,6 +47,10 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter {
profileId: Int, profileId: Int,
entries: Collection<WatchProgressEntry>, entries: Collection<WatchProgressEntry>,
) { ) {
log.d {
"push start profileId=$profileId entries=${entries.size} " +
"items=${entries.debugWatchProgressEntrySummary()}"
}
val syncEntries = entries.map { entry -> val syncEntries = entries.map { entry ->
WatchProgressSyncEntry( WatchProgressSyncEntry(
contentId = entry.parentMetaId, contentId = entry.parentMetaId,
@ -58,12 +69,17 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter {
put("p_entries", json.encodeToJsonElement(syncEntries)) put("p_entries", json.encodeToJsonElement(syncEntries))
} }
SupabaseProvider.client.postgrest.rpc("sync_push_watch_progress", params) SupabaseProvider.client.postgrest.rpc("sync_push_watch_progress", params)
log.d { "push complete profileId=$profileId entries=${syncEntries.size}" }
} }
override suspend fun delete( override suspend fun delete(
profileId: Int, profileId: Int,
entries: Collection<WatchProgressEntry>, entries: Collection<WatchProgressEntry>,
) { ) {
log.d {
"delete start profileId=$profileId entries=${entries.size} " +
"items=${entries.debugWatchProgressEntrySummary()}"
}
val progressKeys = entries.map { entry -> val progressKeys = entries.map { entry ->
if (entry.seasonNumber != null && entry.episodeNumber != null) { if (entry.seasonNumber != null && entry.episodeNumber != null) {
"${entry.parentMetaId}_s${entry.seasonNumber}e${entry.episodeNumber}" "${entry.parentMetaId}_s${entry.seasonNumber}e${entry.episodeNumber}"
@ -76,6 +92,7 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter {
put("p_keys", json.encodeToJsonElement(progressKeys)) put("p_keys", json.encodeToJsonElement(progressKeys))
} }
SupabaseProvider.client.postgrest.rpc("sync_delete_watch_progress", params) SupabaseProvider.client.postgrest.rpc("sync_delete_watch_progress", params)
log.d { "delete complete profileId=$profileId keys=${progressKeys.joinToString(limit = 12)}" }
} }
private fun progressKeyForEntry(entry: WatchProgressEntry): String = private fun progressKeyForEntry(entry: WatchProgressEntry): String =
@ -98,3 +115,53 @@ private data class WatchProgressSyncEntry(
@SerialName("last_watched") val lastWatched: Long = 0, @SerialName("last_watched") val lastWatched: Long = 0,
@SerialName("progress_key") val progressKey: String = "", @SerialName("progress_key") val progressKey: String = "",
) )
private fun Collection<ProgressSyncRecord>.debugProgressRecordSummary(limit: Int = 10): String =
take(limit).joinToString(separator = " | ") { record ->
buildString {
append(record.contentType)
append(":")
append(record.contentId)
if (record.season != null || record.episode != null) {
append(" s=")
append(record.season)
append(" e=")
append(record.episode)
}
append(" video=")
append(record.videoId)
append(" pos=")
append(record.position)
append(" dur=")
append(record.duration)
append(" last=")
append(record.lastWatched)
}
}.ifBlank { "none" }
private fun Collection<WatchProgressEntry>.debugWatchProgressEntrySummary(limit: Int = 10): String =
take(limit).joinToString(separator = " | ") { entry ->
buildString {
append(entry.parentMetaType)
append(":")
append(entry.parentMetaId)
if (entry.seasonNumber != null || entry.episodeNumber != null) {
append(" s=")
append(entry.seasonNumber)
append(" e=")
append(entry.episodeNumber)
}
append(" video=")
append(entry.videoId)
append(" pos=")
append(entry.lastPositionMs)
append(" dur=")
append(entry.durationMs)
append(" pct=")
append(entry.progressPercent)
append(" completed=")
append(entry.isCompleted)
append(" last=")
append(entry.lastUpdatedEpochMs)
}
}.ifBlank { "none" }

View file

@ -118,6 +118,7 @@ data class WatchProgressEntry(
data class WatchProgressUiState( data class WatchProgressUiState(
val entries: List<WatchProgressEntry> = emptyList(), val entries: List<WatchProgressEntry> = emptyList(),
val hasLoadedRemoteProgress: Boolean = false,
) { ) {
val byVideoId: Map<String, WatchProgressEntry> val byVideoId: Map<String, WatchProgressEntry>
get() = entries.associateBy { it.videoId } get() = entries.associateBy { it.videoId }

View file

@ -1,6 +1,8 @@
package com.nuvio.app.features.watchprogress package com.nuvio.app.features.watchprogress
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.player.PlayerPlaybackSnapshot import com.nuvio.app.features.player.PlayerPlaybackSnapshot
@ -11,11 +13,14 @@ import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.trakt.shouldUseTraktProgress as shouldUseTraktProgressSource import com.nuvio.app.features.trakt.shouldUseTraktProgress as shouldUseTraktProgressSource
import com.nuvio.app.features.watching.application.WatchingActions import com.nuvio.app.features.watching.application.WatchingActions
import com.nuvio.app.features.watching.sync.ProgressSyncAdapter import com.nuvio.app.features.watching.sync.ProgressSyncAdapter
import com.nuvio.app.features.watching.sync.ProgressSyncRecord
import com.nuvio.app.features.watching.sync.SupabaseProgressSyncAdapter import com.nuvio.app.features.watching.sync.SupabaseProgressSyncAdapter
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -23,6 +28,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
private const val NUVIO_SYNC_PERIODIC_INTERVAL_MS = 5L * 60L * 1000L
object WatchProgressRepository { object WatchProgressRepository {
private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val log = Logger.withTag("WatchProgressRepository") private val log = Logger.withTag("WatchProgressRepository")
@ -34,6 +41,8 @@ object WatchProgressRepository {
private var currentProfileId: Int = 1 private var currentProfileId: Int = 1
private var entriesByVideoId: MutableMap<String, WatchProgressEntry> = mutableMapOf() private var entriesByVideoId: MutableMap<String, WatchProgressEntry> = mutableMapOf()
private var metadataResolutionJob: Job? = null private var metadataResolutionJob: Job? = null
private var isPullingNuvioSyncFromServer = false
private var hasCompletedInitialNuvioSyncPull = false
internal var syncAdapter: ProgressSyncAdapter = SupabaseProgressSyncAdapter internal var syncAdapter: ProgressSyncAdapter = SupabaseProgressSyncAdapter
init { init {
@ -45,7 +54,10 @@ object WatchProgressRepository {
) )
) { ) {
runCatching { TraktProgressRepository.refreshNow() } runCatching { TraktProgressRepository.refreshNow() }
.onFailure { error -> log.w { "Failed to refresh Trakt progress after auth: ${error.message}" } } .onFailure { error ->
if (error is CancellationException) throw error
log.w { "Failed to refresh Trakt progress after auth: ${error.message}" }
}
} }
publish() publish()
} }
@ -59,7 +71,10 @@ object WatchProgressRepository {
) )
) { ) {
runCatching { TraktProgressRepository.refreshNow() } runCatching { TraktProgressRepository.refreshNow() }
.onFailure { error -> log.w { "Failed to refresh Trakt progress after source change: ${error.message}" } } .onFailure { error ->
if (error is CancellationException) throw error
log.w { "Failed to refresh Trakt progress after source change: ${error.message}" }
}
} }
publish() publish()
} }
@ -72,6 +87,35 @@ object WatchProgressRepository {
} }
} }
} }
syncScope.launch {
while (true) {
delay(NUVIO_SYNC_PERIODIC_INTERVAL_MS)
TraktAuthRepository.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
if (shouldUseTraktProgress()) continue
val authState = AuthRepository.state.value
if (authState !is AuthState.Authenticated || authState.isAnonymous) continue
if (!hasCompletedInitialNuvioSyncPull || isPullingNuvioSyncFromServer) continue
log.d {
"periodic NuvioSync pull start profileId=${ProfileRepository.activeProfileId} " +
"entries=${entriesByVideoId.size}"
}
runCatching { pullFromServer(ProfileRepository.activeProfileId) }
.onSuccess {
log.d {
"periodic NuvioSync pull complete profileId=${ProfileRepository.activeProfileId} " +
"entries=${entriesByVideoId.size}"
}
}
.onFailure { error ->
if (error is CancellationException) throw error
log.w { "Periodic NuvioSync pull failed: ${error.message}" }
}
}
}
} }
fun ensureLoaded() { fun ensureLoaded() {
@ -127,57 +171,93 @@ object WatchProgressRepository {
currentProfileId = profileId currentProfileId = profileId
val useTraktProgress = shouldUseTraktProgress() val useTraktProgress = shouldUseTraktProgress()
log.d {
if (useTraktProgress) { "pullFromServer start profileId=$profileId source=${if (useTraktProgress) "trakt" else "nuvio_sync"} " +
runCatching { TraktProgressRepository.refreshNow() } "localEntries=${entriesByVideoId.size}"
.onFailure { e -> log.e(e) { "Failed to pull Trakt progress" } }
publish()
return
} }
runCatching { if (!useTraktProgress && isPullingNuvioSyncFromServer) {
val serverEntries = syncAdapter.pull(profileId = profileId) log.d { "pullFromServer NuvioSync skipped: pull already in flight profileId=$profileId" }
return
}
if (!useTraktProgress) {
isPullingNuvioSyncFromServer = true
}
val oldLocal = entriesByVideoId.toMap() try {
val newMap = mutableMapOf<String, WatchProgressEntry>() if (useTraktProgress) {
runCatching { TraktProgressRepository.refreshNow() }
serverEntries.forEach { entry -> .onFailure { e ->
val videoId = entry.videoId if (e is CancellationException) throw e
val cached = oldLocal[videoId] log.e(e) { "Failed to pull Trakt progress" }
newMap[videoId] = WatchProgressEntry( }
contentType = entry.contentType, publish()
parentMetaId = entry.contentId, log.d {
parentMetaType = cached?.parentMetaType ?: entry.contentType, "pullFromServer trakt complete entries=${TraktProgressRepository.uiState.value.entries.size} " +
videoId = videoId, "sources=${TraktProgressRepository.uiState.value.entries.debugSourceCounts()} " +
title = cached?.title?.takeIf { it.isNotBlank() } ?: entry.contentId, "items=${TraktProgressRepository.uiState.value.entries.debugWatchProgressEntrySummary()}"
logo = cached?.logo, }
poster = cached?.poster, return
background = cached?.background,
seasonNumber = entry.season,
episodeNumber = entry.episode,
episodeTitle = cached?.episodeTitle,
episodeThumbnail = cached?.episodeThumbnail,
lastPositionMs = entry.position,
durationMs = entry.duration,
lastUpdatedEpochMs = entry.lastWatched,
providerName = cached?.providerName,
providerAddonId = cached?.providerAddonId,
lastStreamTitle = cached?.lastStreamTitle,
lastStreamSubtitle = cached?.lastStreamSubtitle,
pauseDescription = cached?.pauseDescription,
lastSourceUrl = cached?.lastSourceUrl,
isCompleted = isWatchProgressComplete(entry.position, entry.duration, false),
)
} }
entriesByVideoId = newMap runCatching {
hasLoaded = true val serverEntries = syncAdapter.pull(profileId = profileId)
publish() log.d {
persist() "pullFromServer NuvioSync returned ${serverEntries.size} records " +
"items=${serverEntries.debugProgressRecordSummary()}"
}
resolveRemoteMetadata() val oldLocal = entriesByVideoId.toMap()
}.onFailure { e -> val newMap = mutableMapOf<String, WatchProgressEntry>()
log.e(e) { "Failed to pull watch progress from server" }
serverEntries.forEach { entry ->
val videoId = entry.videoId
val cached = oldLocal[videoId]
newMap[videoId] = WatchProgressEntry(
contentType = entry.contentType,
parentMetaId = entry.contentId,
parentMetaType = cached?.parentMetaType ?: entry.contentType,
videoId = videoId,
title = cached?.title?.takeIf { it.isNotBlank() } ?: entry.contentId,
logo = cached?.logo,
poster = cached?.poster,
background = cached?.background,
seasonNumber = entry.season,
episodeNumber = entry.episode,
episodeTitle = cached?.episodeTitle,
episodeThumbnail = cached?.episodeThumbnail,
lastPositionMs = entry.position,
durationMs = entry.duration,
lastUpdatedEpochMs = entry.lastWatched,
providerName = cached?.providerName,
providerAddonId = cached?.providerAddonId,
lastStreamTitle = cached?.lastStreamTitle,
lastStreamSubtitle = cached?.lastStreamSubtitle,
pauseDescription = cached?.pauseDescription,
lastSourceUrl = cached?.lastSourceUrl,
isCompleted = isWatchProgressComplete(entry.position, entry.duration, false),
)
}
entriesByVideoId = newMap
hasLoaded = true
hasCompletedInitialNuvioSyncPull = true
publish()
persist()
log.d {
"pullFromServer NuvioSync applied entries=${entriesByVideoId.size} " +
"items=${entriesByVideoId.values.debugWatchProgressEntrySummary()}"
}
resolveRemoteMetadata()
}.onFailure { e ->
if (e is CancellationException) throw e
log.e(e) { "Failed to pull watch progress from server" }
}
} finally {
if (!useTraktProgress) {
isPullingNuvioSyncFromServer = false
}
} }
} }
@ -186,7 +266,15 @@ object WatchProgressRepository {
.filter { it.poster.isNullOrBlank() || it.background.isNullOrBlank() } .filter { it.poster.isNullOrBlank() || it.background.isNullOrBlank() }
.groupBy { it.parentMetaId to it.contentType } .groupBy { it.parentMetaId to it.contentType }
if (needsResolution.isEmpty()) return if (needsResolution.isEmpty()) {
log.d { "resolveRemoteMetadata skipped: all entries have artwork" }
return
}
log.d {
"resolveRemoteMetadata start groups=${needsResolution.size} " +
"entries=${needsResolution.values.sumOf { it.size }} " +
"keys=${needsResolution.keys.joinToString(limit = 12) { (metaId, type) -> "$type:$metaId" }}"
}
metadataResolutionJob?.cancel() metadataResolutionJob?.cancel()
metadataResolutionJob = syncScope.launch { metadataResolutionJob = syncScope.launch {
@ -201,7 +289,11 @@ object WatchProgressRepository {
val (metaId, metaType) = key val (metaId, metaType) = key
val meta = runCatching { val meta = runCatching {
MetaDetailsRepository.fetch(metaType, metaId) MetaDetailsRepository.fetch(metaType, metaId)
}.getOrNull() ?: continue }.getOrNull()
if (meta == null) {
log.d { "resolveRemoteMetadata miss type=$metaType id=$metaId entries=${entries.size}" }
continue
}
for (entry in entries) { for (entry in entries) {
val episodeVideo = if (entry.seasonNumber != null && entry.episodeNumber != null) { val episodeVideo = if (entry.seasonNumber != null && entry.episodeNumber != null) {
@ -224,8 +316,13 @@ object WatchProgressRepository {
} }
publish() publish()
log.d {
"resolveRemoteMetadata applied type=$metaType id=$metaId entries=${entries.size} " +
"metaVideos=${meta.videos.size}"
}
} }
persist() persist()
log.d { "resolveRemoteMetadata complete entries=${entriesByVideoId.size}" }
} }
} }
@ -350,6 +447,10 @@ object WatchProgressRepository {
isEnded = snapshot.isEnded, isEnded = snapshot.isEnded,
) )
if (!isCompleted && !shouldStoreWatchProgress(positionMs = positionMs, durationMs = durationMs)) { if (!isCompleted && !shouldStoreWatchProgress(positionMs = positionMs, durationMs = durationMs)) {
log.d {
"upsert skipped below threshold video=${session.videoId} content=${session.parentMetaId} " +
"s=${session.seasonNumber} e=${session.episodeNumber} pos=$positionMs dur=$durationMs ended=${snapshot.isEnded}"
}
return return
} }
@ -383,6 +484,10 @@ object WatchProgressRepository {
} }
val useTraktProgress = shouldUseTraktProgress() val useTraktProgress = shouldUseTraktProgress()
log.d {
"upsert progress source=${if (useTraktProgress) "trakt" else "nuvio_sync"} " +
"entry=${entry.debugSummary()} snapshotEnded=${snapshot.isEnded}"
}
entriesByVideoId[session.videoId] = entry entriesByVideoId[session.videoId] = entry
if (useTraktProgress) { if (useTraktProgress) {
@ -403,7 +508,9 @@ object WatchProgressRepository {
syncScope.launch { syncScope.launch {
runCatching { runCatching {
val profileId = ProfileRepository.activeProfileId val profileId = ProfileRepository.activeProfileId
log.d { "pushScrobbleToServer profileId=$profileId entry=${entry.debugSummary()}" }
syncAdapter.push(profileId = profileId, entries = listOf(entry)) syncAdapter.push(profileId = profileId, entries = listOf(entry))
log.d { "pushScrobbleToServer complete profileId=$profileId video=${entry.videoId}" }
}.onFailure { e -> }.onFailure { e ->
log.e(e) { "Failed to push watch progress scrobble" } log.e(e) { "Failed to push watch progress scrobble" }
} }
@ -416,7 +523,12 @@ object WatchProgressRepository {
runCatching { runCatching {
if (entries.isEmpty()) return@runCatching if (entries.isEmpty()) return@runCatching
val profileId = ProfileRepository.activeProfileId val profileId = ProfileRepository.activeProfileId
log.d {
"pushDeleteToServer profileId=$profileId entries=${entries.size} " +
"items=${entries.debugWatchProgressEntrySummary()}"
}
syncAdapter.delete(profileId = profileId, entries = entries) syncAdapter.delete(profileId = profileId, entries = entries)
log.d { "pushDeleteToServer complete profileId=$profileId entries=${entries.size}" }
}.onFailure { e -> }.onFailure { e ->
log.e(e) { "Failed to push watch progress delete" } log.e(e) { "Failed to push watch progress delete" }
} }
@ -426,8 +538,18 @@ object WatchProgressRepository {
private fun publish() { private fun publish() {
val entries = currentEntries() val entries = currentEntries()
val sortedEntries = entries.sortedByDescending { it.lastUpdatedEpochMs } val sortedEntries = entries.sortedByDescending { it.lastUpdatedEpochMs }
log.d {
"publish source=${if (shouldUseTraktProgress()) "trakt" else "nuvio_sync"} " +
"entries=${sortedEntries.size} cw=${sortedEntries.continueWatchingEntries().size} " +
"sources=${sortedEntries.debugSourceCounts()} items=${sortedEntries.debugWatchProgressEntrySummary()}"
}
_uiState.value = WatchProgressUiState( _uiState.value = WatchProgressUiState(
entries = sortedEntries, entries = sortedEntries,
hasLoadedRemoteProgress = if (shouldUseTraktProgress()) {
TraktProgressRepository.uiState.value.hasLoadedRemoteProgress
} else {
hasLoaded
},
) )
} }
@ -453,3 +575,67 @@ object WatchProgressRepository {
} }
} }
private fun ProgressSyncRecord.debugSummary(): String =
buildString {
append(contentType)
append(":")
append(contentId)
if (season != null || episode != null) {
append(" s=")
append(season)
append(" e=")
append(episode)
}
append(" video=")
append(videoId)
append(" pos=")
append(position)
append(" dur=")
append(duration)
append(" last=")
append(lastWatched)
}
private fun Collection<ProgressSyncRecord>.debugProgressRecordSummary(limit: Int = 10): String =
take(limit).joinToString(separator = " | ") { it.debugSummary() }.ifBlank { "none" }
private fun WatchProgressEntry.debugSummary(): String =
buildString {
append(parentMetaType)
append(":")
append(parentMetaId)
if (seasonNumber != null || episodeNumber != null) {
append(" s=")
append(seasonNumber)
append(" e=")
append(episodeNumber)
}
append(" video=")
append(videoId)
append(" pos=")
append(lastPositionMs)
append(" dur=")
append(durationMs)
append(" pct=")
append(progressPercent)
append(" completed=")
append(isCompleted)
append(" effectiveCompleted=")
append(isEffectivelyCompleted)
append(" src=")
append(source)
append(" last=")
append(lastUpdatedEpochMs)
}
private fun Collection<WatchProgressEntry>.debugWatchProgressEntrySummary(limit: Int = 10): String =
take(limit).joinToString(separator = " | ") { it.debugSummary() }.ifBlank { "none" }
private fun Collection<WatchProgressEntry>.debugSourceCounts(): String =
groupingBy { it.source }
.eachCount()
.entries
.sortedBy { it.key }
.joinToString(separator = ",") { "${it.key}=${it.value}" }
.ifBlank { "none" }