From b3082eb4120067fd02bbfbe0a9519eac67557e0f Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:27:19 +0530 Subject: [PATCH] feat: normalize watch progress entries and update completion logic --- .../features/trakt/TraktProgressRepository.kt | 19 ++-- .../watching/application/WatchingState.kt | 24 +++--- .../watchprogress/WatchProgressModels.kt | 86 +++++++++++++------ .../watchprogress/WatchProgressRepository.kt | 2 +- .../watchprogress/WatchProgressRules.kt | 25 +++--- .../nuvio/app/features/home/HomeScreenTest.kt | 1 + .../watchprogress/WatchProgressRulesTest.kt | 53 +++++++++++- 7 files changed, 153 insertions(+), 57 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt index 8c51e9065..b5ab5cdc0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt @@ -3,6 +3,7 @@ package com.nuvio.app.features.trakt import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.httpGetTextWithHeaders import com.nuvio.app.features.details.MetaDetailsRepository +import com.nuvio.app.features.watchprogress.WatchProgressCompletionPercentThreshold import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.buildPlaybackVideoId import kotlinx.coroutines.CancellationException @@ -157,9 +158,10 @@ object TraktProgressRepository { fun applyOptimisticProgress(entry: WatchProgressEntry) { if (!TraktAuthRepository.isAuthenticated.value) return val current = _uiState.value.entries.associateBy { it.videoId }.toMutableMap() - val existing = current[entry.videoId] - if (existing == null || entry.lastUpdatedEpochMs >= existing.lastUpdatedEpochMs) { - current[entry.videoId] = entry + val normalizedEntry = entry.normalizedCompletion() + val existing = current[normalizedEntry.videoId] + if (existing == null || normalizedEntry.lastUpdatedEpochMs >= existing.lastUpdatedEpochMs) { + current[normalizedEntry.videoId] = normalizedEntry } _uiState.value = _uiState.value.copy(entries = current.values.sortedByDescending { it.lastUpdatedEpochMs }) } @@ -240,7 +242,8 @@ object TraktProgressRepository { private fun mergeNewestByVideoId(entries: List): List { val mergedByVideoId = linkedMapOf() - entries.forEach { entry -> + entries.forEach { rawEntry -> + val entry = rawEntry.normalizedCompletion() val existing = mergedByVideoId[entry.videoId] if (existing == null || entry.lastUpdatedEpochMs > existing.lastUpdatedEpochMs) { mergedByVideoId[entry.videoId] = entry @@ -365,9 +368,9 @@ object TraktProgressRepository { lastPositionMs = 0L, durationMs = 0L, lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex), - isCompleted = false, + isCompleted = progressPercent >= WatchProgressCompletionPercentThreshold, progressPercent = progressPercent, - ) + ).normalizedCompletion() } private fun mapPlaybackEpisode(item: TraktPlaybackItem, fallbackIndex: Int): WatchProgressEntry? { @@ -399,9 +402,9 @@ object TraktProgressRepository { lastPositionMs = 0L, durationMs = 0L, lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex), - isCompleted = false, + isCompleted = progressPercent >= WatchProgressCompletionPercentThreshold, progressPercent = progressPercent, - ) + ).normalizedCompletion() } private fun mapHistoryEpisode(item: TraktHistoryEpisodeItem, fallbackIndex: Int): WatchProgressEntry? { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt index 43c05175f..9e29639a1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt @@ -91,17 +91,19 @@ object WatchingState { } private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord = - WatchingProgressRecord( - content = WatchingContentRef(type = parentMetaType, id = parentMetaId), - videoId = videoId, - seasonNumber = seasonNumber, - episodeNumber = episodeNumber, - lastUpdatedEpochMs = lastUpdatedEpochMs, - lastPositionMs = lastPositionMs, - isCompleted = isCompleted, - episodeTitle = episodeTitle, - episodeThumbnail = episodeThumbnail, - ) + normalizedCompletion().let { entry -> + WatchingProgressRecord( + content = WatchingContentRef(type = entry.parentMetaType, id = entry.parentMetaId), + videoId = entry.videoId, + seasonNumber = entry.seasonNumber, + episodeNumber = entry.episodeNumber, + lastUpdatedEpochMs = entry.lastUpdatedEpochMs, + lastPositionMs = entry.lastPositionMs, + isCompleted = entry.isCompleted, + episodeTitle = entry.episodeTitle, + episodeThumbnail = entry.episodeThumbnail, + ) + } private fun WatchedItem.toDomainWatchedRecord(): WatchingWatchedRecord = WatchingWatchedRecord( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt index 34150cf5d..f9b64d77b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt @@ -4,6 +4,8 @@ import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.watching.domain.WatchingContentRef import kotlinx.serialization.Serializable +internal const val WatchProgressCompletionPercentThreshold = 99.5f + @Serializable enum class ContinueWatchingSectionStyle { Wide, @@ -36,9 +38,17 @@ data class WatchProgressEntry( val isCompleted: Boolean = false, val progressPercent: Float? = null, ) { + val normalizedProgressPercent: Float? + get() = progressPercent?.coerceIn(0f, 100f) + + val isEffectivelyCompleted: Boolean + get() = isCompleted || + (normalizedProgressPercent?.let { it >= WatchProgressCompletionPercentThreshold } == true) || + (durationMs > 0L && lastPositionMs >= durationMs) + val progressFraction: Float get() { - progressPercent?.let { explicitPercent -> + normalizedProgressPercent?.let { explicitPercent -> return (explicitPercent / 100f).coerceIn(0f, 1f) } return if (durationMs > 0L) { @@ -52,14 +62,41 @@ data class WatchProgressEntry( get() = seasonNumber != null && episodeNumber != null val isResumable: Boolean - get() = !isCompleted + get() = !isEffectivelyCompleted + + fun normalizedCompletion(): WatchProgressEntry { + val completed = isEffectivelyCompleted + val normalizedPositionMs = when { + completed && durationMs > 0L -> durationMs + else -> lastPositionMs.coerceAtLeast(0L) + } + val normalizedPercent = when { + normalizedProgressPercent != null -> normalizedProgressPercent + completed && durationMs <= 0L -> 100f + else -> null + } + + return if ( + completed == isCompleted && + normalizedPositionMs == lastPositionMs && + normalizedPercent == progressPercent + ) { + this + } else { + copy( + lastPositionMs = normalizedPositionMs, + isCompleted = completed, + progressPercent = normalizedPercent, + ) + } + } fun resolveResumePosition(actualDurationMs: Long): Long { if (actualDurationMs <= 0L) return lastPositionMs.coerceAtLeast(0L) if (durationMs > 0L && lastPositionMs > 0L) { return lastPositionMs.coerceIn(0L, actualDurationMs) } - progressPercent?.let { percent -> + normalizedProgressPercent?.let { percent -> val fraction = (percent / 100f).coerceIn(0f, 1f) return (actualDurationMs * fraction).toLong() } @@ -126,17 +163,18 @@ data class ContinueWatchingPreferencesUiState( ) internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { - val explicitResumeProgressFraction = progressPercent + val normalizedEntry = normalizedCompletion() + val explicitResumeProgressFraction = normalizedEntry.normalizedProgressPercent ?.takeIf { durationMs <= 0L && it > 0f } ?.let { explicitPercent -> (explicitPercent / 100f).coerceIn(0f, 1f) } - val subtitle = if (seasonNumber != null && episodeNumber != null) { + val subtitle = if (normalizedEntry.seasonNumber != null && normalizedEntry.episodeNumber != null) { buildString { append("S") - append(seasonNumber) + append(normalizedEntry.seasonNumber) append("E") - append(episodeNumber) - episodeTitle?.takeIf { it.isNotBlank() }?.let { + append(normalizedEntry.episodeNumber) + normalizedEntry.episodeTitle?.takeIf { it.isNotBlank() }?.let { append(" • ") append(it) } @@ -146,24 +184,24 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { } return ContinueWatchingItem( - parentMetaId = parentMetaId, - parentMetaType = parentMetaType, - videoId = videoId, - title = title, + parentMetaId = normalizedEntry.parentMetaId, + parentMetaType = normalizedEntry.parentMetaType, + videoId = normalizedEntry.videoId, + title = normalizedEntry.title, subtitle = subtitle, - imageUrl = episodeThumbnail ?: background ?: poster, - logo = logo, - poster = poster, - background = background, - seasonNumber = seasonNumber, - episodeNumber = episodeNumber, - episodeTitle = episodeTitle, - episodeThumbnail = episodeThumbnail, - pauseDescription = pauseDescription, - resumePositionMs = if (explicitResumeProgressFraction != null) 0L else lastPositionMs, + imageUrl = normalizedEntry.episodeThumbnail ?: normalizedEntry.background ?: normalizedEntry.poster, + logo = normalizedEntry.logo, + poster = normalizedEntry.poster, + background = normalizedEntry.background, + seasonNumber = normalizedEntry.seasonNumber, + episodeNumber = normalizedEntry.episodeNumber, + episodeTitle = normalizedEntry.episodeTitle, + episodeThumbnail = normalizedEntry.episodeThumbnail, + pauseDescription = normalizedEntry.pauseDescription, + resumePositionMs = if (explicitResumeProgressFraction != null) 0L else normalizedEntry.lastPositionMs, resumeProgressFraction = explicitResumeProgressFraction, - durationMs = durationMs, - progressFraction = progressFraction, + durationMs = normalizedEntry.durationMs, + progressFraction = normalizedEntry.progressFraction, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index 718ab8501..578b2c1f0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -294,7 +294,7 @@ object WatchProgressRepository { pauseDescription = session.pauseDescription, lastSourceUrl = session.lastSourceUrl, isCompleted = isCompleted, - ) + ).normalizedCompletion() entriesByVideoId[session.videoId] = entry if (shouldUseTraktProgress()) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt index b5f1c273c..d12f80c2e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt @@ -28,6 +28,7 @@ internal object WatchProgressCodec { fun decodeEntries(payload: String): List = runCatching { json.decodeFromString(payload).entries + .map(WatchProgressEntry::normalizedCompletion) }.getOrDefault(emptyList()) fun encodeEntries(entries: Collection): String = @@ -76,17 +77,19 @@ internal fun List.continueWatchingEntries( } private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord = - WatchingProgressRecord( + normalizedCompletion().let { entry -> + WatchingProgressRecord( content = WatchingContentRef( - type = parentMetaType, - id = parentMetaId, + type = entry.parentMetaType, + id = entry.parentMetaId, ), - videoId = videoId, - seasonNumber = seasonNumber, - episodeNumber = episodeNumber, - lastUpdatedEpochMs = lastUpdatedEpochMs, - lastPositionMs = lastPositionMs, - isCompleted = isCompleted, - episodeTitle = episodeTitle, - episodeThumbnail = episodeThumbnail, + videoId = entry.videoId, + seasonNumber = entry.seasonNumber, + episodeNumber = entry.episodeNumber, + lastUpdatedEpochMs = entry.lastUpdatedEpochMs, + lastPositionMs = entry.lastPositionMs, + isCompleted = entry.isCompleted, + episodeTitle = entry.episodeTitle, + episodeThumbnail = entry.episodeThumbnail, ) + } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt index 37b701ef4..849211a7a 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt @@ -42,6 +42,7 @@ class HomeScreenTest { val inProgress = progressEntry( videoId = "show:1:5", title = "Show", + episodeNumber = 5, episodeTitle = "The Wolf and the Lion", lastUpdatedEpochMs = 500L, ) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt index c1b8e409c..4021283bc 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt @@ -94,6 +94,49 @@ class WatchProgressRulesTest { assertEquals(2, result.size) } + @Test + fun `continue watching excludes explicit 100 percent entries even when completion flag is false`() { + val completedByPercent = entry( + videoId = "movie-complete", + lastUpdatedEpochMs = 20L, + lastPositionMs = 0L, + durationMs = 0L, + isCompleted = false, + progressPercent = 100f, + ) + val inProgress = entry( + videoId = "movie-progress", + lastUpdatedEpochMs = 10L, + lastPositionMs = 120_000L, + durationMs = 1_000_000L, + progressPercent = 12f, + ) + + val result = listOf(completedByPercent, inProgress).continueWatchingEntries() + + assertEquals(listOf("movie-progress"), result.map { it.videoId }) + } + + @Test + fun `codec normalizes completed entries inferred from percent`() { + val payload = WatchProgressCodec.encodeEntries( + listOf( + entry( + videoId = "movie-complete", + lastPositionMs = 0L, + durationMs = 0L, + isCompleted = false, + progressPercent = 100f, + ), + ), + ) + + val decoded = WatchProgressCodec.decodeEntries(payload) + + assertEquals(1, decoded.size) + assertTrue(decoded.single().isCompleted) + } + @Test fun `build playback video id uses season and episode when present`() { assertEquals("show:1:2", buildPlaybackVideoId(parentMetaId = "show", seasonNumber = 1, episodeNumber = 2, fallbackVideoId = "fallback")) @@ -107,6 +150,10 @@ class WatchProgressRulesTest { seasonNumber: Int? = null, episodeNumber: Int? = null, lastUpdatedEpochMs: Long = 1L, + lastPositionMs: Long = 120_000L, + durationMs: Long = 1_000_000L, + isCompleted: Boolean = false, + progressPercent: Float? = null, ): WatchProgressEntry = WatchProgressEntry( contentType = if (seasonNumber != null && episodeNumber != null) "series" else "movie", @@ -116,8 +163,10 @@ class WatchProgressRulesTest { title = "Title", seasonNumber = seasonNumber, episodeNumber = episodeNumber, - lastPositionMs = 120_000L, - durationMs = 1_000_000L, + lastPositionMs = lastPositionMs, + durationMs = durationMs, lastUpdatedEpochMs = lastUpdatedEpochMs, + isCompleted = isCompleted, + progressPercent = progressPercent, ) }