ref: cache and log cleanup

This commit is contained in:
tapframe 2026-05-19 01:32:11 +05:30
parent 8464f4db48
commit 5aee64e25e
5 changed files with 103 additions and 608 deletions

View file

@ -14,7 +14,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.touchlab.kermit.Logger
import com.nuvio.app.core.network.NetworkCondition
import com.nuvio.app.core.network.NetworkStatusRepository
import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding
@ -356,43 +355,6 @@ fun HomeScreen(
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) {
addonsUiState.addons.mapNotNull { addon -> addon.manifest }
}
@ -430,35 +392,51 @@ fun HomeScreen(
LaunchedEffect(
completedSeriesCandidates,
cachedNextUpItems,
visibleContinueWatchingEntries,
metaProviderKey,
continueWatchingPreferences.showUnairedNextUp,
) {
if (completedSeriesCandidates.isEmpty()) {
homeCwLog.d {
"next-up resolve skipped: no completed series candidates " +
"entries=${effectiveWatchProgressEntries.size} sources=${effectiveWatchProgressEntries.debugSourceCounts()}"
}
nextUpItemsBySeries = emptyMap()
processedNextUpContentIds = emptySet()
return@LaunchedEffect
}
if (metaProviderKey.isEmpty()) {
homeCwLog.d {
"next-up resolve deferred: no meta providers candidates=${completedSeriesCandidates.size} " +
"candidates=${completedSeriesCandidates.debugCompletedSeriesSummary()}"
val cachedResolvedNextUpItems = completedSeriesCandidates.mapNotNull { candidate ->
val cached = cachedNextUpItems[candidate.content.id] ?: return@mapNotNull null
val item = cached.second
if (
item.nextUpSeedSeasonNumber != candidate.seasonNumber ||
item.nextUpSeedEpisodeNumber != candidate.episodeNumber
) {
return@mapNotNull null
}
candidate.content.id to cached
}.toMap()
val candidatesToResolve = completedSeriesCandidates.filter { candidate ->
candidate.content.id !in cachedResolvedNextUpItems
}
if (candidatesToResolve.isEmpty()) {
nextUpItemsBySeries = cachedResolvedNextUpItems
processedNextUpContentIds = completedSeriesCandidates.mapTo(mutableSetOf()) { candidate ->
candidate.content.id
}
saveContinueWatchingSnapshots(
nextUpItemsBySeries = cachedResolvedNextUpItems,
visibleContinueWatchingEntries = visibleContinueWatchingEntries,
todayIsoDate = CurrentDateProvider.todayIsoDate(),
)
return@LaunchedEffect
}
if (metaProviderKey.isEmpty()) {
return@LaunchedEffect
}
val todayIsoDate = CurrentDateProvider.todayIsoDate()
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 freshResults = candidatesToResolve.map { completedEntry ->
async {
semaphore.withPermit {
val meta = MetaDetailsRepository.fetch(
@ -466,10 +444,6 @@ fun HomeScreen(
id = completedEntry.content.id,
)
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(
@ -479,84 +453,28 @@ fun HomeScreen(
showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp,
)
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)
.toUpNextContinueWatchingItem(nextEpisode)
if (nextUpDismissKey(item.parentMetaId, item.nextUpSeedSeasonNumber, item.nextUpSeedEpisodeNumber) in continueWatchingPreferences.dismissedNextUpKeys) {
homeCwLog.d { "next-up dismissed item=${item.debugSummary()}" }
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)
}
}
}.awaitAll().filterNotNull().toMap()
val results = cachedResolvedNextUpItems + freshResults
nextUpItemsBySeries = results
processedNextUpContentIds = completedSeriesCandidates.mapTo(mutableSetOf()) { candidate ->
candidate.content.id
}
val nextUpCache = results.mapNotNull { (contentId, pair) ->
val item = pair.second
CachedNextUpItem(
contentId = contentId,
contentType = item.parentMetaType,
name = item.title,
poster = item.poster,
backdrop = item.background,
logo = item.logo,
videoId = item.videoId,
season = item.seasonNumber,
episode = item.episodeNumber,
episodeTitle = item.episodeTitle,
episodeThumbnail = item.episodeThumbnail,
pauseDescription = item.pauseDescription,
released = item.released,
hasAired = item.released?.let { released ->
isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = released)
} ?: true,
lastWatched = pair.first,
sortTimestamp = pair.first,
seedSeason = item.nextUpSeedSeasonNumber,
seedEpisode = item.nextUpSeedEpisodeNumber,
)
}
val inProgressCache = visibleContinueWatchingEntries.map { entry ->
CachedInProgressItem(
contentId = entry.parentMetaId,
contentType = entry.contentType,
name = entry.title,
poster = entry.poster,
backdrop = entry.background,
logo = entry.logo,
videoId = entry.videoId,
season = entry.seasonNumber,
episode = entry.episodeNumber,
episodeTitle = entry.episodeTitle,
episodeThumbnail = entry.episodeThumbnail,
pauseDescription = entry.pauseDescription,
position = entry.lastPositionMs,
duration = entry.durationMs,
lastWatched = entry.lastUpdatedEpochMs,
progressPercent = entry.progressPercent,
)
}
ContinueWatchingEnrichmentCache.saveSnapshots(
nextUp = nextUpCache,
inProgress = inProgressCache,
saveContinueWatchingSnapshots(
nextUpItemsBySeries = results,
visibleContinueWatchingEntries = visibleContinueWatchingEntries,
todayIsoDate = todayIsoDate,
)
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 }
@ -784,7 +702,6 @@ fun HomeScreen(
private const val HOME_CATALOG_PREVIEW_LIMIT = 18
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(
entries: List<WatchProgressEntry>,
@ -1090,6 +1007,62 @@ private data class HomeContinueWatchingCandidate(
val isProgressEntry: Boolean,
)
private fun saveContinueWatchingSnapshots(
nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>,
visibleContinueWatchingEntries: List<WatchProgressEntry>,
todayIsoDate: String,
) {
val nextUpCache = nextUpItemsBySeries.mapNotNull { (contentId, pair) ->
val item = pair.second
CachedNextUpItem(
contentId = contentId,
contentType = item.parentMetaType,
name = item.title,
poster = item.poster,
backdrop = item.background,
logo = item.logo,
videoId = item.videoId,
season = item.seasonNumber,
episode = item.episodeNumber,
episodeTitle = item.episodeTitle,
episodeThumbnail = item.episodeThumbnail,
pauseDescription = item.pauseDescription,
released = item.released,
hasAired = item.released?.let { released ->
isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = released)
} ?: true,
lastWatched = pair.first,
sortTimestamp = pair.first,
seedSeason = item.nextUpSeedSeasonNumber,
seedEpisode = item.nextUpSeedEpisodeNumber,
)
}
val inProgressCache = visibleContinueWatchingEntries.map { entry ->
CachedInProgressItem(
contentId = entry.parentMetaId,
contentType = entry.contentType,
name = entry.title,
poster = entry.poster,
backdrop = entry.background,
logo = entry.logo,
videoId = entry.videoId,
season = entry.seasonNumber,
episode = entry.episodeNumber,
episodeTitle = entry.episodeTitle,
episodeThumbnail = entry.episodeThumbnail,
pauseDescription = entry.pauseDescription,
position = entry.lastPositionMs,
duration = entry.durationMs,
lastWatched = entry.lastUpdatedEpochMs,
progressPercent = entry.progressPercent,
)
}
ContinueWatchingEnrichmentCache.saveSnapshots(
nextUp = nextUpCache,
inProgress = inProgressCache,
)
}
private fun CompletedSeriesCandidate.toContinueWatchingSeed(meta: com.nuvio.app.features.details.MetaDetails) =
WatchProgressEntry(
contentType = content.type,
@ -1201,83 +1174,3 @@ private fun ContinueWatchingItem.withFallbackMetadata(
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

@ -111,12 +111,10 @@ object TraktProgressRepository {
val requestId = nextRefreshRequestId()
val headers = TraktAuthRepository.authorizedHeaders()
if (headers == null) {
log.d { "refreshNow request=$requestId skipped: missing authorized headers" }
_uiState.value = TraktProgressUiState()
return
}
log.d { "refreshNow request=$requestId start currentEntries=${_uiState.value.entries.size}" }
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
val playbackEntries = runCatching {
@ -140,10 +138,6 @@ object TraktProgressRepository {
errorMessage = null,
hasLoadedRemoteProgress = false,
)
log.d {
"refreshNow request=$requestId playback applied entries=${playbackEntries.size} " +
"sources=${playbackEntries.debugSourceCounts()} items=${playbackEntries.debugWatchProgressSummary()}"
}
if (playbackEntries.isNotEmpty()) {
launchHydration(requestId = requestId, entries = playbackEntries)
@ -178,11 +172,6 @@ object TraktProgressRepository {
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)
@ -212,10 +201,6 @@ object TraktProgressRepository {
isLoading = false,
errorMessage = null,
)
log.d {
"hydrate request=$requestId applied hydrated=${hydrated.size} merged=${merged.size} " +
"items=${merged.debugWatchProgressSummary()}"
}
}
}
@ -228,10 +213,6 @@ object TraktProgressRepository {
current[normalizedEntry.videoId] = normalizedEntry
}
_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) {
@ -239,7 +220,6 @@ object TraktProgressRepository {
if (videoId.isBlank()) return
val filtered = _uiState.value.entries.filterNot { it.videoId == videoId }
_uiState.value = _uiState.value.copy(entries = filtered)
log.d { "optimistic removal videoId=$videoId entries=${filtered.size}" }
}
fun applyOptimisticRemoval(
@ -260,10 +240,6 @@ object TraktProgressRepository {
}
}
_uiState.value = _uiState.value.copy(entries = filtered)
log.d {
"optimistic removal contentId=$normalizedContentId s=$seasonNumber e=$episodeNumber " +
"entries=${filtered.size}"
}
}
suspend fun removeProgress(
@ -275,7 +251,6 @@ object TraktProgressRepository {
if (normalizedContentId.isBlank()) return
val headers = TraktAuthRepository.authorizedHeaders() ?: return
log.d { "removeProgress start contentId=$normalizedContentId s=$seasonNumber e=$episodeNumber" }
applyOptimisticRemoval(
contentId = normalizedContentId,
seasonNumber = seasonNumber,
@ -343,12 +318,10 @@ object TraktProgressRepository {
}
}
log.d { "removeProgress complete contentId=$normalizedContentId refreshing" }
refreshNow()
}
private suspend fun fetchPlaybackEntries(headers: Map<String, String>): List<WatchProgressEntry> = withContext(Dispatchers.Default) {
log.d { "fetchPlaybackEntries start" }
val payloads = coroutineScope {
val moviesPayload = async {
httpGetTextWithHeaders(
@ -371,10 +344,6 @@ object TraktProgressRepository {
val moviePlayback = json.decodeFromString<List<TraktPlaybackItem>>(moviesPayload)
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 ->
mapPlaybackMovie(item = item, fallbackIndex = index)
@ -384,15 +353,10 @@ object TraktProgressRepository {
}
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) {
log.d { "fetchHistoryEntries start limit=$HISTORY_LIMIT" }
val payloads = coroutineScope {
val historyPayload = async {
httpGetTextWithHeaders(
@ -414,10 +378,6 @@ object TraktProgressRepository {
val movieHistoryPayload = payloads[1]
val episodeHistory = json.decodeFromString<List<TraktHistoryEpisodeItem>>(historyPayload)
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
.mapIndexedNotNull { index, item -> mapHistoryEpisode(item = item, fallbackIndex = index) }
@ -427,10 +387,6 @@ object TraktProgressRepository {
.distinctBy { entry -> entry.videoId }
val merged = mergeNewestByVideoId(completedEpisodes + completedMovies)
log.d {
"fetchHistoryEntries mapped episodes=${completedEpisodes.size} movies=${completedMovies.size} " +
"merged=${merged.size} items=${merged.debugWatchProgressSummary()}"
}
merged
}
@ -444,10 +400,6 @@ object TraktProgressRepository {
headers = headers,
)
val watchedShows = json.decodeFromString<List<TraktWatchedShowItem>>(payload)
log.d {
"fetchWatchedShowSeedEntries raw shows=${watchedShows.size} " +
"items=${watchedShows.debugWatchedShowSummary()}"
}
val mapped = watchedShows
.mapNotNull { item ->
mapWatchedShowSeed(
@ -456,10 +408,6 @@ object TraktProgressRepository {
)
}
.sortedByDescending { entry -> entry.lastUpdatedEpochMs }
log.d {
"fetchWatchedShowSeedEntries mapped=${mapped.size} useFurthest=$useFurthestEpisode " +
"items=${mapped.debugWatchProgressSummary()}"
}
mapped
}
@ -914,165 +862,3 @@ private data class TraktEpisode(
@SerialName("number") val number: Int? = 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,6 +1,5 @@
package com.nuvio.app.features.watching.sync
import co.touchlab.kermit.Logger
import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import io.github.jan.supabase.postgrest.postgrest
@ -13,18 +12,16 @@ import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put
object SupabaseProgressSyncAdapter : ProgressSyncAdapter {
private val log = Logger.withTag("NuvioSyncProgress")
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
override suspend fun pull(profileId: Int): List<ProgressSyncRecord> {
log.d { "pull start profileId=$profileId" }
val params = buildJsonObject { put("p_profile_id", profileId) }
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_watch_progress", params)
val serverEntries = result.decodeList<WatchProgressSyncEntry>()
val records = serverEntries.map { entry ->
return serverEntries.map { entry ->
ProgressSyncRecord(
contentId = entry.contentId,
contentType = entry.contentType,
@ -36,21 +33,12 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter {
lastWatched = entry.lastWatched,
)
}
log.d {
"pull returned raw=${serverEntries.size} records=${records.size} " +
"items=${records.debugProgressRecordSummary()}"
}
return records
}
override suspend fun push(
profileId: Int,
entries: Collection<WatchProgressEntry>,
) {
log.d {
"push start profileId=$profileId entries=${entries.size} " +
"items=${entries.debugWatchProgressEntrySummary()}"
}
val syncEntries = entries.map { entry ->
WatchProgressSyncEntry(
contentId = entry.parentMetaId,
@ -69,17 +57,12 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter {
put("p_entries", json.encodeToJsonElement(syncEntries))
}
SupabaseProvider.client.postgrest.rpc("sync_push_watch_progress", params)
log.d { "push complete profileId=$profileId entries=${syncEntries.size}" }
}
override suspend fun delete(
profileId: Int,
entries: Collection<WatchProgressEntry>,
) {
log.d {
"delete start profileId=$profileId entries=${entries.size} " +
"items=${entries.debugWatchProgressEntrySummary()}"
}
val progressKeys = entries.map { entry ->
if (entry.seasonNumber != null && entry.episodeNumber != null) {
"${entry.parentMetaId}_s${entry.seasonNumber}e${entry.episodeNumber}"
@ -92,7 +75,6 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter {
put("p_keys", json.encodeToJsonElement(progressKeys))
}
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 =
@ -115,53 +97,3 @@ private data class WatchProgressSyncEntry(
@SerialName("last_watched") val lastWatched: Long = 0,
@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

@ -60,6 +60,7 @@ internal object ContinueWatchingEnrichmentCache {
}
private const val storageKey = "cw_enrichment_cache"
private var lastPayloadHash: Int? = null
fun getNextUpSnapshot(): List<CachedNextUpItem> =
loadPayload()?.nextUp ?: emptyList()
@ -75,11 +76,17 @@ internal object ContinueWatchingEnrichmentCache {
fun saveSnapshots(
nextUp: List<CachedNextUpItem>,
inProgress: List<CachedInProgressItem>,
force: Boolean = false,
) {
val payload = CachedEnrichmentPayload(nextUp = nextUp, inProgress = inProgress)
val payloadHash = payload.hashCode()
if (!force && lastPayloadHash == payloadHash) return
val encoded = runCatching {
json.encodeToString(CachedEnrichmentPayload(nextUp = nextUp, inProgress = inProgress))
json.encodeToString(payload)
}.getOrNull() ?: return
ContinueWatchingEnrichmentStorage.savePayload(ProfileScopedKey.of(storageKey), encoded)
lastPayloadHash = payloadHash
}
private fun loadPayload(): CachedEnrichmentPayload? {
@ -87,6 +94,8 @@ internal object ContinueWatchingEnrichmentCache {
?: return null
return runCatching {
json.decodeFromString<CachedEnrichmentPayload>(raw)
}.getOrNull()
}.getOrNull()?.also { payload ->
lastPayloadHash = payload.hashCode()
}
}
}

View file

@ -13,7 +13,6 @@ import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.trakt.shouldUseTraktProgress as shouldUseTraktProgressSource
import com.nuvio.app.features.watching.application.WatchingActions
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 kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@ -99,17 +98,7 @@ object WatchProgressRepository {
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}" }
@ -171,13 +160,8 @@ object WatchProgressRepository {
currentProfileId = profileId
val useTraktProgress = shouldUseTraktProgress()
log.d {
"pullFromServer start profileId=$profileId source=${if (useTraktProgress) "trakt" else "nuvio_sync"} " +
"localEntries=${entriesByVideoId.size}"
}
if (!useTraktProgress && isPullingNuvioSyncFromServer) {
log.d { "pullFromServer NuvioSync skipped: pull already in flight profileId=$profileId" }
return
}
if (!useTraktProgress) {
@ -192,20 +176,11 @@ object WatchProgressRepository {
log.e(e) { "Failed to pull Trakt progress" }
}
publish()
log.d {
"pullFromServer trakt complete entries=${TraktProgressRepository.uiState.value.entries.size} " +
"sources=${TraktProgressRepository.uiState.value.entries.debugSourceCounts()} " +
"items=${TraktProgressRepository.uiState.value.entries.debugWatchProgressEntrySummary()}"
}
return
}
runCatching {
val serverEntries = syncAdapter.pull(profileId = profileId)
log.d {
"pullFromServer NuvioSync returned ${serverEntries.size} records " +
"items=${serverEntries.debugProgressRecordSummary()}"
}
val oldLocal = entriesByVideoId.toMap()
val newMap = mutableMapOf<String, WatchProgressEntry>()
@ -244,10 +219,6 @@ object WatchProgressRepository {
hasCompletedInitialNuvioSyncPull = true
publish()
persist()
log.d {
"pullFromServer NuvioSync applied entries=${entriesByVideoId.size} " +
"items=${entriesByVideoId.values.debugWatchProgressEntrySummary()}"
}
resolveRemoteMetadata()
}.onFailure { e ->
@ -267,14 +238,8 @@ object WatchProgressRepository {
.groupBy { it.parentMetaId to it.contentType }
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 = syncScope.launch {
@ -291,7 +256,6 @@ object WatchProgressRepository {
MetaDetailsRepository.fetch(metaType, metaId)
}.getOrNull()
if (meta == null) {
log.d { "resolveRemoteMetadata miss type=$metaType id=$metaId entries=${entries.size}" }
continue
}
@ -316,13 +280,8 @@ object WatchProgressRepository {
}
publish()
log.d {
"resolveRemoteMetadata applied type=$metaType id=$metaId entries=${entries.size} " +
"metaVideos=${meta.videos.size}"
}
}
persist()
log.d { "resolveRemoteMetadata complete entries=${entriesByVideoId.size}" }
}
}
@ -447,10 +406,6 @@ object WatchProgressRepository {
isEnded = snapshot.isEnded,
)
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
}
@ -484,10 +439,6 @@ object WatchProgressRepository {
}
val useTraktProgress = shouldUseTraktProgress()
log.d {
"upsert progress source=${if (useTraktProgress) "trakt" else "nuvio_sync"} " +
"entry=${entry.debugSummary()} snapshotEnded=${snapshot.isEnded}"
}
entriesByVideoId[session.videoId] = entry
if (useTraktProgress) {
@ -508,9 +459,7 @@ object WatchProgressRepository {
syncScope.launch {
runCatching {
val profileId = ProfileRepository.activeProfileId
log.d { "pushScrobbleToServer profileId=$profileId entry=${entry.debugSummary()}" }
syncAdapter.push(profileId = profileId, entries = listOf(entry))
log.d { "pushScrobbleToServer complete profileId=$profileId video=${entry.videoId}" }
}.onFailure { e ->
log.e(e) { "Failed to push watch progress scrobble" }
}
@ -523,12 +472,7 @@ object WatchProgressRepository {
runCatching {
if (entries.isEmpty()) return@runCatching
val profileId = ProfileRepository.activeProfileId
log.d {
"pushDeleteToServer profileId=$profileId entries=${entries.size} " +
"items=${entries.debugWatchProgressEntrySummary()}"
}
syncAdapter.delete(profileId = profileId, entries = entries)
log.d { "pushDeleteToServer complete profileId=$profileId entries=${entries.size}" }
}.onFailure { e ->
log.e(e) { "Failed to push watch progress delete" }
}
@ -538,11 +482,6 @@ object WatchProgressRepository {
private fun publish() {
val entries = currentEntries()
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(
entries = sortedEntries,
hasLoadedRemoteProgress = if (shouldUseTraktProgress()) {
@ -575,67 +514,3 @@ 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" }